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 @@ + + + + + + Lauftest - Shuttle Run Timer + + + +
+

Lauftest

+ +
+

Einstellungen

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Zeit pro Kegel (Start): + 10.29 s +
+ +
+
+

Prognose

+ +
+ +
+ Geschwindigkeit (km/h) + Zeit pro Kegel (s) +
+
+
+
+ + +
+
+ + + 50% +
+
+ + + Countdown vor Piepton +
+
+ + + Bei Steigerung +
+
+ + + Detailliertes Log anzeigen +
+
+ +
+ +
+
+ Gesamtlaufzeit + 00:00 +
+
+ + + + + + + + + + + + + + + + + + +
+
+ Aktuelle Geschwindigkeit + 7.0 km/h +
+
+ Nächste Steigerung in + 03:00 +
+
+ Nächster Piepton in + -- +
+
+ Zeit pro Kegel + -- s +
+
+
+ +
+
+

Protokoll

+ +
+
+ Zeit + Ereignis + Geschw. + Kegel-Zeit + Distanz +
+
+ +
+
+ + + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..d28399d --- /dev/null +++ b/style.css @@ -0,0 +1,829 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + min-height: 100vh; + color: #fff; + padding: 20px; +} + +.container { + max-width: 600px; + margin: 0 auto; +} + +h1 { + text-align: center; + font-size: 2.5rem; + margin-bottom: 30px; + font-weight: 300; + letter-spacing: 2px; +} + +h2 { + font-size: 1.1rem; + font-weight: 500; + margin-bottom: 20px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Settings Panel */ +.settings-panel { + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 24px; + margin-bottom: 24px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: opacity 0.3s ease; +} + +.settings-panel.disabled { + opacity: 0.5; + pointer-events: none; +} + +.settings-panel.disabled #startStopBtn, +.settings-panel.disabled .audio-controls { + pointer-events: auto; + opacity: 1; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-size: 0.85rem; + color: #94a3b8; + font-weight: 500; +} + +.form-group input { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 12px 16px; + font-size: 1.1rem; + color: #fff; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.form-group input:focus { + outline: none; + border-color: #3b82f6; + background: rgba(255, 255, 255, 0.15); +} + +.form-group input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Info Display */ +.info-display { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: 8px; + margin-bottom: 16px; +} + +.info-label { + font-size: 0.9rem; + color: #94a3b8; +} + +.info-value { + font-size: 1.1rem; + font-weight: 600; + color: #3b82f6; + font-variant-numeric: tabular-nums; +} + +/* Forecast Button */ +.btn-forecast { + width: 100%; + padding: 12px; + font-size: 0.95rem; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + cursor: pointer; + background: rgba(255, 255, 255, 0.05); + color: #94a3b8; + transition: all 0.2s ease; + margin-bottom: 16px; +} + +.btn-forecast:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); + color: #fff; +} + +/* Forecast Panel */ +.forecast-panel { + display: none; + background: rgba(0, 0, 0, 0.3); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.forecast-panel.visible { + display: block; +} + +.forecast-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.forecast-header h3 { + font-size: 0.95rem; + font-weight: 500; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 1px; + margin: 0; +} + +.btn-close { + background: none; + border: none; + color: #94a3b8; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.2s ease; +} + +.btn-close:hover { + color: #fff; +} + +#forecastGraph { + width: 100%; + height: auto; + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); +} + +.forecast-legend { + display: flex; + justify-content: center; + gap: 24px; + margin-top: 12px; + font-size: 0.8rem; + color: #94a3b8; +} + +.legend-dot { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +.legend-dot.speed { + background: #f59e0b; +} + +.legend-dot.time { + background: #3b82f6; +} + +/* Audio Controls */ +.audio-controls { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 24px; + padding: 16px; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; +} + +.sound-select { + display: flex; + align-items: center; + gap: 16px; +} + +.sound-select label { + font-size: 0.85rem; + color: #94a3b8; + font-weight: 500; + min-width: 80px; +} + +.sound-select select { + flex: 1; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 10px 16px; + font-size: 1rem; + color: #fff; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.sound-select select:focus { + outline: none; + border-color: #3b82f6; + background: rgba(255, 255, 255, 0.15); +} + +.sound-select select option { + background: #1a1a2e; + color: #fff; +} + +/* Toggle Control */ +.toggle-control { + display: flex; + align-items: center; + gap: 16px; +} + +.toggle-control > label:first-child { + font-size: 0.85rem; + color: #94a3b8; + font-weight: 500; + min-width: 80px; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 28px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.2); + transition: 0.3s; + border-radius: 28px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 4px; + bottom: 4px; + background: #fff; + transition: 0.3s; + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(22px); +} + +.toggle-hint { + font-size: 0.8rem; + color: #64748b; + font-style: italic; +} + +/* Volume Control */ +.volume-control { + display: flex; + align-items: center; + gap: 16px; +} + +.volume-control label { + font-size: 0.85rem; + color: #94a3b8; + font-weight: 500; + min-width: 80px; +} + +.volume-control input[type="range"] { + flex: 1; + height: 8px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; + outline: none; +} + +.volume-control input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + border-radius: 50%; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.volume-control input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: 0 0 12px rgba(59, 130, 246, 0.5); +} + +.volume-control input[type="range"]::-moz-range-thumb { + width: 24px; + height: 24px; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + border-radius: 50%; + cursor: pointer; + border: none; +} + +.volume-control #volumeValue { + font-size: 1rem; + font-weight: 600; + min-width: 45px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Buttons */ +#startStopBtn { + width: 100%; + padding: 16px; + font-size: 1.2rem; + font-weight: 600; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 2px; +} + +.btn-start { + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: #fff; +} + +.btn-start:hover { + background: linear-gradient(135deg, #16a34a 0%, #15803d 100%); + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(34, 197, 94, 0.4); +} + +.btn-stop { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: #fff; +} + +.btn-stop:hover { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4); +} + +/* Display Panel */ +.display-panel { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.stat-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 24px; + text-align: center; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: transform 0.2s ease; +} + +.stat-card.primary { + grid-column: span 2; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(37, 99, 235, 0.2) 100%); + border-color: rgba(59, 130, 246, 0.3); +} + +.stat-card.primary .stat-value { + font-size: 4rem; +} + +.stat-card.visual-card { + grid-column: span 2; + padding: 16px; +} + +.track-visual { + width: 100%; + height: auto; +} + +.stat-label { + display: block; + font-size: 0.85rem; + color: #94a3b8; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.stat-value { + display: block; + font-size: 2.5rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat-value small { + font-size: 0.5em; + font-weight: 400; + opacity: 0.7; +} + +/* Beep animation */ +.stat-card.beep-flash { + animation: beepFlash 0.3s ease; +} + +@keyframes beepFlash { + 0% { + background: rgba(34, 197, 94, 0.5); + transform: scale(1.02); + } + 100% { + background: rgba(255, 255, 255, 0.05); + transform: scale(1); + } +} + +/* Log Panel */ +.log-panel { + display: none; + margin-top: 24px; + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 20px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.log-panel.visible { + display: block; +} + +.log-title-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.log-panel h3 { + font-size: 1rem; + font-weight: 500; + margin: 0; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 1px; +} + +.btn-download { + padding: 8px 16px; + font-size: 0.85rem; + font-weight: 500; + border: none; + border-radius: 8px; + cursor: pointer; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: #fff; + transition: all 0.2s ease; +} + +.btn-download:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); +} + +.log-header { + display: grid; + grid-template-columns: 80px 1fr 70px 70px 70px; + gap: 12px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin-bottom: 8px; + font-size: 0.8rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; +} + +.log-entries { + max-height: 300px; + overflow-y: auto; +} + +.log-entry { + display: grid; + grid-template-columns: 80px 1fr 70px 70px 70px; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-entry.event-start { + color: #22c55e; +} + +.log-entry.event-beep { + color: #fff; +} + +.log-entry.event-speed { + color: #f59e0b; + font-weight: 500; +} + +.log-entry.event-stop { + color: #ef4444; +} + +.log-entries::-webkit-scrollbar { + width: 6px; +} + +.log-entries::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; +} + +.log-entries::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.log-entries::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Responsive - Tablet */ +@media (max-width: 768px) { + body { + padding: 12px; + } + + .stat-card.primary .stat-value { + font-size: 3.5rem; + } +} + +/* Responsive - Mobile */ +@media (max-width: 480px) { + body { + padding: 8px; + } + + h1 { + font-size: 1.8rem; + margin-bottom: 20px; + } + + h2 { + font-size: 0.95rem; + margin-bottom: 16px; + } + + /* Settings Panel */ + .settings-panel { + padding: 16px; + border-radius: 12px; + } + + .form-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .form-group label { + font-size: 0.8rem; + } + + .form-group input { + padding: 14px; + font-size: 1.2rem; + } + + /* Info Display */ + .info-display { + flex-direction: column; + gap: 4px; + text-align: center; + padding: 10px; + } + + .info-value { + font-size: 1.3rem; + } + + /* Audio Controls */ + .audio-controls { + padding: 12px; + gap: 14px; + } + + .sound-select, + .volume-control, + .toggle-control { + flex-wrap: wrap; + gap: 10px; + } + + .sound-select label, + .volume-control label, + .toggle-control > label:first-child { + min-width: 70px; + font-size: 0.8rem; + } + + .sound-select select { + padding: 12px; + font-size: 1rem; + } + + .toggle-hint { + width: 100%; + margin-top: -4px; + font-size: 0.75rem; + } + + /* Start/Stop Button */ + #startStopBtn { + padding: 18px; + font-size: 1.3rem; + border-radius: 10px; + } + + /* Display Panel */ + .display-panel { + gap: 10px; + } + + .stat-card { + padding: 16px; + border-radius: 12px; + } + + .stat-card.primary { + padding: 20px; + } + + .stat-card.primary .stat-value { + font-size: 3rem; + } + + .stat-label { + font-size: 0.7rem; + margin-bottom: 6px; + } + + .stat-value { + font-size: 1.8rem; + } + + .stat-value small { + font-size: 0.55em; + } + + /* Visual Card */ + .stat-card.visual-card { + padding: 12px; + } + + /* Log Panel */ + .log-panel { + padding: 14px; + margin-top: 16px; + } + + .log-title-row { + flex-direction: column; + gap: 12px; + align-items: stretch; + } + + .log-panel h3 { + text-align: center; + } + + .btn-download { + width: 100%; + padding: 12px; + } + + .log-header { + grid-template-columns: 65px 1fr 55px 55px 55px; + gap: 8px; + padding: 6px 8px; + font-size: 0.7rem; + } + + .log-entry { + grid-template-columns: 65px 1fr 55px 55px 55px; + gap: 8px; + padding: 8px; + font-size: 0.8rem; + } + + .log-entries { + max-height: 250px; + } +} + +/* Responsive - Mobile - Forecast */ +@media (max-width: 480px) { + .btn-forecast { + padding: 14px; + font-size: 1rem; + } + + .forecast-panel { + padding: 12px; + } + + .forecast-legend { + flex-direction: column; + gap: 8px; + align-items: center; + } +} + +/* Extra small screens */ +@media (max-width: 360px) { + .stat-card.primary .stat-value { + font-size: 2.5rem; + } + + .stat-value { + font-size: 1.5rem; + } + + .log-header, + .log-entry { + grid-template-columns: 55px 1fr 45px 45px 45px; + font-size: 0.7rem; + } +}