diff --git a/app.js b/app.js new file mode 100644 index 0000000..35e23e2 --- /dev/null +++ b/app.js @@ -0,0 +1,749 @@ +// 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(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..cde4ab9 --- /dev/null +++ b/index.html @@ -0,0 +1,155 @@ + + +
+ + +