// DOM Elements
const settingsPanel = document.getElementById('settingsPanel');
const startStopBtn = document.getElementById('startStopBtn');
const distanceInput = document.getElementById('distance');
const startSpeedInput = document.getElementById('startSpeed');
const speedIncrementInput = document.getElementById('speedIncrement');
const intervalMinutesInput = document.getElementById('intervalMinutes');
const volumeSlider = document.getElementById('volume');
const volumeValue = document.getElementById('volumeValue');
const beepSoundSelect = document.getElementById('beepSound');
const guidanceCheckbox = document.getElementById('guidance');
const doubleBeepCheckbox = document.getElementById('doubleBeep');
const showLogCheckbox = document.getElementById('showLog');
const logPanel = document.getElementById('logPanel');
const logEntries = document.getElementById('logEntries');
const downloadCsvBtn = document.getElementById('downloadCsvBtn');
const totalTimeDisplay = document.getElementById('totalTime');
const currentSpeedDisplay = document.getElementById('currentSpeed');
const nextIncreaseDisplay = document.getElementById('nextIncrease');
const nextBeepDisplay = document.getElementById('nextBeep');
const runnerDot = document.getElementById('runnerDot');
const progressLine = document.getElementById('progressLine');
const lapTimeInfo = document.getElementById('lapTimeInfo');
const lapTimeDisplay = document.getElementById('lapTime');
const showForecastBtn = document.getElementById('showForecastBtn');
const closeForecastBtn = document.getElementById('closeForecastBtn');
const forecastPanel = document.getElementById('forecastPanel');
const forecastGraph = document.getElementById('forecastGraph');
const forecastCtx = forecastGraph.getContext('2d');
// State
let isRunning = false;
let startTime = null;
let animationFrameId = null;
let audioContext = null;
// Settings (read from inputs when starting)
let distance = 20;
let startSpeed = 7;
let speedIncrement = 1;
let intervalSeconds = 180;
// Calculated values during run
let currentSpeed = 7;
let pendingSpeed = null; // Speed increase waiting to be applied after next beep
let lastBeepTime = 0;
let beepInterval = 0;
let lastTickTime = 0; // for rapid tick timing
let lapDirection = 1; // 1 = left to right, -1 = right to left
let lapCount = 0;
let totalDistance = 0;
let logData = []; // Store log entries for CSV export
// Track visual constants
const TRACK_LEFT = 55;
const TRACK_RIGHT = 245;
// Initialize Audio Context
function initAudio() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
audioContext.resume();
}
}
// Sound configurations
const soundTypes = {
sharp: { waveform: 'square', frequency: 880 },
normal: { waveform: 'sine', frequency: 880 },
low: { waveform: 'square', frequency: 440 }
};
// Get current volume (0 to 1)
function getVolume() {
return parseInt(volumeSlider.value) / 100;
}
// Get current sound settings
function getSoundSettings() {
const selected = beepSoundSelect.value;
return soundTypes[selected] || soundTypes.sharp;
}
// Play beep sound
function playBeep(frequencyOverride = null, duration = 0.15) {
if (!audioContext) return;
const volume = getVolume();
if (volume === 0) return;
const sound = getSoundSettings();
const frequency = frequencyOverride || sound.frequency;
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = sound.waveform;
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
gainNode.gain.setValueAtTime(volume, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + duration);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + duration);
}
// Play double beep for speed increase (higher pitch)
function playDoubleBeep() {
const sound = getSoundSettings();
const higherFreq = sound.frequency * 1.25; // 25% higher pitch
playBeep(higherFreq, 0.1);
setTimeout(() => playBeep(higherFreq, 0.1), 150);
}
// Play countdown tick (short, high-pitched click)
function playTick() {
if (!audioContext) return;
const volume = getVolume() * 0.5; // 50% of main volume
if (volume === 0) return;
const sound = getSoundSettings();
const tickFreq = sound.frequency * 2; // much higher pitch for tick
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine'; // always sine for ticks - cleaner click sound
oscillator.frequency.setValueAtTime(tickFreq, audioContext.currentTime);
gainNode.gain.setValueAtTime(volume, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.05);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.05);
}
// Calculate beep interval based on speed
function calculateBeepInterval(speedKmh) {
// time = distance / speed
// speed in m/s = speedKmh / 3.6
// time in seconds = distance / (speedKmh / 3.6) = distance * 3.6 / speedKmh
return (distance * 3.6) / speedKmh;
}
// Update lap time info in form
function updateLapTimeInfo() {
const dist = parseFloat(distanceInput.value) || 20;
const speed = parseFloat(startSpeedInput.value) || 7;
const lapTime = (dist * 3.6) / speed;
lapTimeInfo.textContent = `${lapTime.toFixed(2)} s`;
}
// Draw forecast graph
function drawForecastGraph() {
const dist = parseFloat(distanceInput.value) || 20;
const startSpd = parseFloat(startSpeedInput.value) || 7;
const spdIncrement = parseFloat(speedIncrementInput.value) || 1;
const interval = (parseFloat(intervalMinutesInput.value) || 1) * 60;
// Calculate data points until 20 km/h is reached
const maxSpeedCap = 20; // realistic max running speed
const dataPoints = [];
let currentTime = 0;
let lapNum = 0;
while (true) {
const level = Math.floor(currentTime / interval);
const speed = startSpd + (level * spdIncrement);
// Stop if speed exceeds cap
if (speed > maxSpeedCap) {
break;
}
const lapTime = (dist * 3.6) / speed;
lapNum++;
currentTime += lapTime;
dataPoints.push({
lap: lapNum,
time: currentTime,
speed: speed,
lapTime: lapTime
});
}
// Canvas setup
const canvas = forecastGraph;
const ctx = forecastCtx;
const dpr = window.devicePixelRatio || 1;
// Set canvas size
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
const padding = { top: 30, right: 50, bottom: 40, left: 50 };
const graphWidth = width - padding.left - padding.right;
const graphHeight = height - padding.top - padding.bottom;
// Clear canvas
ctx.clearRect(0, 0, width, height);
if (dataPoints.length < 2) return;
// Find ranges
const maxLap = dataPoints[dataPoints.length - 1].lap;
const maxSpeed = Math.min(Math.max(...dataPoints.map(d => d.speed)), maxSpeedCap);
const maxLapTime = Math.max(...dataPoints.map(d => d.lapTime));
const minLapTime = Math.min(...dataPoints.map(d => d.lapTime));
// Helper functions
const xScale = (lap) => padding.left + (lap / maxLap) * graphWidth;
const yScaleSpeed = (speed) => padding.top + graphHeight - ((speed - startSpd) / (maxSpeedCap - startSpd)) * graphHeight;
const yScaleLapTime = (time) => padding.top + ((time - minLapTime) / (maxLapTime - minLapTime + 0.5)) * graphHeight;
// Draw grid lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
// Horizontal grid lines
for (let i = 0; i <= 5; i++) {
const y = padding.top + (i / 5) * graphHeight;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// Vertical grid lines (every 10 laps or appropriate interval)
const lapInterval = Math.ceil(maxLap / 10);
for (let lap = 0; lap <= maxLap; lap += lapInterval) {
const x = xScale(lap);
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, height - padding.bottom);
ctx.stroke();
}
// Draw speed line (orange)
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2;
ctx.beginPath();
dataPoints.forEach((point, i) => {
const x = xScale(point.lap);
const y = yScaleSpeed(point.speed);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Draw lap time line (blue)
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
dataPoints.forEach((point, i) => {
const x = xScale(point.lap);
const y = yScaleLapTime(point.lapTime);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Draw axes labels
ctx.fillStyle = '#94a3b8';
ctx.font = '11px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
// X-axis label
ctx.fillText('Kegel', width / 2, height - 8);
// X-axis values
for (let lap = 0; lap <= maxLap; lap += lapInterval) {
const x = xScale(lap);
ctx.fillText(lap.toString(), x, height - padding.bottom + 18);
}
// Y-axis labels (left - speed)
ctx.save();
ctx.translate(12, height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = '#f59e0b';
ctx.fillText('km/h', 0, 0);
ctx.restore();
// Y-axis labels (right - lap time)
ctx.save();
ctx.translate(width - 8, height / 2);
ctx.rotate(Math.PI / 2);
ctx.fillStyle = '#3b82f6';
ctx.fillText('Sekunden', 0, 0);
ctx.restore();
// Y-axis values (left - speed)
ctx.textAlign = 'right';
ctx.fillStyle = '#f59e0b';
for (let i = 0; i <= 4; i++) {
const speed = startSpd + (i / 4) * (maxSpeedCap - startSpd);
const y = yScaleSpeed(speed);
ctx.fillText(speed.toFixed(0), padding.left - 8, y + 4);
}
// Y-axis values (right - lap time)
ctx.textAlign = 'left';
ctx.fillStyle = '#3b82f6';
for (let i = 0; i <= 4; i++) {
const lapTime = maxLapTime - (i / 4) * (maxLapTime - minLapTime);
const y = yScaleLapTime(lapTime);
ctx.fillText(lapTime.toFixed(1), width - padding.right + 8, y + 4);
}
// Draw total info
ctx.fillStyle = '#fff';
ctx.textAlign = 'left';
ctx.font = 'bold 12px -apple-system, BlinkMacSystemFont, sans-serif';
const totalDist = maxLap * dist;
const totalTimeMin = Math.floor(dataPoints[dataPoints.length - 1].time / 60);
const totalTimeSec = Math.floor(dataPoints[dataPoints.length - 1].time % 60);
ctx.fillText(`${maxLap} Kegel = ${totalDist}m bis 20 km/h (${totalTimeMin}:${totalTimeSec.toString().padStart(2, '0')})`, padding.left, 18);
}
// Show/hide forecast
function showForecast() {
forecastPanel.classList.add('visible');
drawForecastGraph();
}
function hideForecast() {
forecastPanel.classList.remove('visible');
}
// Update track visual
function updateTrackVisual(progress) {
// progress is 0 to 1 (0 = just beeped, 1 = about to beep)
const trackWidth = TRACK_RIGHT - TRACK_LEFT;
let dotX;
if (lapDirection === 1) {
// Moving left to right
dotX = TRACK_LEFT + (trackWidth * progress);
} else {
// Moving right to left
dotX = TRACK_RIGHT - (trackWidth * progress);
}
// Update runner dot position
runnerDot.setAttribute('cx', dotX);
// Update progress line
if (lapDirection === 1) {
progressLine.setAttribute('x1', TRACK_LEFT);
progressLine.setAttribute('x2', dotX);
} else {
progressLine.setAttribute('x1', dotX);
progressLine.setAttribute('x2', TRACK_RIGHT);
}
// Change color based on progress (green → yellow → red as approaching beep)
let color;
if (progress < 0.6) {
color = '#22c55e'; // green
} else if (progress < 0.85) {
color = '#eab308'; // yellow
} else {
color = '#ef4444'; // red
}
progressLine.setAttribute('stroke', color);
}
// Reset track visual
function resetTrackVisual() {
runnerDot.setAttribute('cx', TRACK_LEFT);
progressLine.setAttribute('x1', TRACK_LEFT);
progressLine.setAttribute('x2', TRACK_LEFT);
progressLine.setAttribute('stroke', '#22c55e');
lapDirection = 1;
lapCount = 0;
}
// Add log entry
function addLogEntry(time, event, speed, kegelTime, dist, eventType) {
// Store in data array for CSV export
logData.push({ time, event, speed, kegelTime, dist, eventType });
const entry = document.createElement('div');
entry.className = `log-entry event-${eventType}`;
entry.innerHTML = `
${time}
${event}
${speed}
${kegelTime}
${dist}
`;
logEntries.appendChild(entry);
// Auto-scroll to bottom
logEntries.scrollTop = logEntries.scrollHeight;
}
// Clear log
function clearLog() {
logEntries.innerHTML = '';
logData = [];
}
// Download log as CSV
function downloadLogCSV() {
if (logData.length === 0) return;
// CSV header
const headers = ['Zeit', 'Ereignis', 'Geschwindigkeit', 'Kegel-Zeit', 'Distanz'];
const csvRows = [headers.join(';')];
// Add data rows
logData.forEach(entry => {
const row = [
entry.time,
entry.event,
entry.speed,
entry.kegelTime,
entry.dist
];
csvRows.push(row.join(';'));
});
const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
// Create download link
const link = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
link.setAttribute('href', url);
link.setAttribute('download', `lauftest-${timestamp}.csv`);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// Toggle log visibility
function updateLogVisibility() {
if (showLogCheckbox.checked) {
logPanel.classList.add('visible');
} else {
logPanel.classList.remove('visible');
}
}
// Format time as MM:SS or HH:MM:SS
function formatTime(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format time with milliseconds for log (MM:SS.mmm)
function formatTimeLog(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toFixed(2).padStart(5, '0')}`;
}
// Format time with decimals for countdown
function formatTimeDecimal(seconds) {
if (seconds < 10) {
return seconds.toFixed(1);
}
return formatTime(seconds);
}
// Read settings from inputs
function readSettings() {
distance = parseFloat(distanceInput.value) || 20;
startSpeed = parseFloat(startSpeedInput.value) || 7;
speedIncrement = parseFloat(speedIncrementInput.value) || 1;
intervalSeconds = (parseFloat(intervalMinutesInput.value) || 3) * 60;
}
// Calculate current speed based on elapsed time
function calculateCurrentSpeed(elapsedSeconds) {
const intervals = Math.floor(elapsedSeconds / intervalSeconds);
return startSpeed + (intervals * speedIncrement);
}
// Update display
function updateDisplay(elapsedSeconds) {
// Total time
totalTimeDisplay.textContent = formatTime(elapsedSeconds);
// Check if speed should increase (but don't apply until next beep)
const newSpeed = calculateCurrentSpeed(elapsedSeconds);
if (newSpeed !== currentSpeed && newSpeed !== pendingSpeed) {
// Store as pending - will be applied after the next beep
pendingSpeed = newSpeed;
}
currentSpeedDisplay.innerHTML = `${currentSpeed.toFixed(1)} km/h`;
lapTimeDisplay.innerHTML = `${beepInterval.toFixed(2)} s`;
// Time until next speed increase
const timeInCurrentInterval = elapsedSeconds % intervalSeconds;
const timeUntilIncrease = intervalSeconds - timeInCurrentInterval;
nextIncreaseDisplay.textContent = formatTime(timeUntilIncrease);
// Time until next beep
const timeSinceLastBeep = elapsedSeconds - lastBeepTime;
const timeUntilBeep = beepInterval - timeSinceLastBeep;
const progress = Math.min(timeSinceLastBeep / beepInterval, 1);
// Update track visual
updateTrackVisual(progress);
if (timeUntilBeep <= 0) {
// Time for a beep
playBeep();
lastBeepTime = elapsedSeconds;
// Flip lap direction
lapDirection *= -1;
lapCount++;
totalDistance += distance;
// Log beep (with current speed before any pending increase)
addLogEntry(
formatTimeLog(elapsedSeconds),
`Kegel ${lapCount}`,
`${currentSpeed.toFixed(1)} km/h`,
`${beepInterval.toFixed(2)} s`,
`${totalDistance} m`,
'beep'
);
// Apply pending speed increase after the beep
if (pendingSpeed !== null) {
currentSpeed = pendingSpeed;
beepInterval = calculateBeepInterval(currentSpeed);
if (doubleBeepCheckbox.checked) {
playDoubleBeep();
}
// Log speed increase (at the time it actually takes effect)
addLogEntry(
formatTimeLog(elapsedSeconds),
`Steigerung auf ${currentSpeed.toFixed(1)} km/h`,
`${currentSpeed.toFixed(1)} km/h`,
`${beepInterval.toFixed(2)} s`,
`${totalDistance} m`,
'speed'
);
pendingSpeed = null;
}
nextBeepDisplay.textContent = formatTimeDecimal(beepInterval);
// Flash effect on beep card
const beepCard = nextBeepDisplay.closest('.stat-card');
beepCard.classList.add('beep-flash');
setTimeout(() => beepCard.classList.remove('beep-flash'), 300);
} else {
nextBeepDisplay.textContent = formatTimeDecimal(timeUntilBeep);
// Rapid countdown ticks (if guidance is enabled and beep interval > 3 seconds)
if (guidanceCheckbox.checked && beepInterval > 3 && timeUntilBeep <= 2 && timeUntilBeep > 0.1) {
const timeSinceLastTick = elapsedSeconds - lastTickTime;
const tickInterval = 0.25; // tick every 0.25 seconds
if (timeSinceLastTick >= tickInterval) {
playTick();
lastTickTime = elapsedSeconds;
}
}
}
}
// Main animation loop
function tick() {
if (!isRunning) return;
const now = performance.now();
const elapsedSeconds = (now - startTime) / 1000;
updateDisplay(elapsedSeconds);
animationFrameId = requestAnimationFrame(tick);
}
// Start the test
function startTest() {
initAudio();
readSettings();
currentSpeed = startSpeed;
pendingSpeed = null;
beepInterval = calculateBeepInterval(currentSpeed);
lastBeepTime = 0;
lastTickTime = 0;
lapDirection = 1;
lapCount = 0;
totalDistance = 0;
startTime = performance.now();
isRunning = true;
// Clear and add start log entry
clearLog();
downloadCsvBtn.style.display = 'none';
addLogEntry('00:00.00', 'Start', `${currentSpeed.toFixed(1)} km/h`, `${beepInterval.toFixed(2)} s`, '0 m', 'start');
// Update UI
settingsPanel.classList.add('disabled');
startStopBtn.textContent = 'Stop';
startStopBtn.classList.remove('btn-start');
startStopBtn.classList.add('btn-stop');
// Disable inputs
distanceInput.disabled = true;
startSpeedInput.disabled = true;
speedIncrementInput.disabled = true;
intervalMinutesInput.disabled = true;
// Play initial beep
playBeep();
// Start the loop
tick();
}
// Stop the test
function stopTest() {
// Log stop entry before resetting
if (startTime) {
const elapsedSeconds = (performance.now() - startTime) / 1000;
addLogEntry(
formatTimeLog(elapsedSeconds),
'Stopp',
`${currentSpeed.toFixed(1)} km/h`,
'--',
`${totalDistance} m`,
'stop'
);
}
isRunning = false;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
// Update UI
settingsPanel.classList.remove('disabled');
startStopBtn.textContent = 'Start';
startStopBtn.classList.remove('btn-stop');
startStopBtn.classList.add('btn-start');
// Enable inputs
distanceInput.disabled = false;
startSpeedInput.disabled = false;
speedIncrementInput.disabled = false;
intervalMinutesInput.disabled = false;
// Reset displays
nextBeepDisplay.textContent = '--';
lapTimeDisplay.innerHTML = '-- s';
resetTrackVisual();
// Show download button if there's log data
if (logData.length > 0) {
downloadCsvBtn.style.display = 'block';
}
}
// Toggle start/stop
function toggleTest() {
if (isRunning) {
stopTest();
} else {
startTest();
}
}
// Event listeners
startStopBtn.addEventListener('click', toggleTest);
// Volume slider - always active
volumeSlider.addEventListener('input', () => {
volumeValue.textContent = `${volumeSlider.value}%`;
});
// Log visibility toggle
showLogCheckbox.addEventListener('change', updateLogVisibility);
// CSV download button
downloadCsvBtn.addEventListener('click', downloadLogCSV);
// Forecast buttons
showForecastBtn.addEventListener('click', showForecast);
closeForecastBtn.addEventListener('click', hideForecast);
// Initialize display
currentSpeedDisplay.innerHTML = `${startSpeedInput.value || 7} km/h`;
nextIncreaseDisplay.textContent = formatTime((intervalMinutesInput.value || 3) * 60);
// Update initial display when inputs change
[startSpeedInput, intervalMinutesInput, speedIncrementInput].forEach(input => {
input.addEventListener('input', () => {
if (!isRunning) {
currentSpeedDisplay.innerHTML = `${startSpeedInput.value || 7} km/h`;
nextIncreaseDisplay.textContent = formatTime((intervalMinutesInput.value || 1) * 60);
updateLapTimeInfo();
if (forecastPanel.classList.contains('visible')) {
drawForecastGraph();
}
}
});
});
// Update lap time info when distance changes
distanceInput.addEventListener('input', () => {
if (!isRunning) {
updateLapTimeInfo();
if (forecastPanel.classList.contains('visible')) {
drawForecastGraph();
}
}
});
// Initialize lap time info
updateLapTimeInfo();