// 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();