Upload files to "/"

This commit is contained in:
2026-01-23 14:07:45 +01:00
parent ee736b70a5
commit 81d3185f81
3 changed files with 1733 additions and 0 deletions

749
app.js Normal file
View File

@@ -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 = `
<span>${time}</span>
<span>${event}</span>
<span>${speed}</span>
<span>${kegelTime}</span>
<span>${dist}</span>
`;
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)} <small>km/h</small>`;
lapTimeDisplay.innerHTML = `${beepInterval.toFixed(2)} <small>s</small>`;
// 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 = '-- <small>s</small>';
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} <small>km/h</small>`;
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} <small>km/h</small>`;
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();

155
index.html Normal file
View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lauftest - Shuttle Run Timer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Lauftest</h1>
<div class="settings-panel" id="settingsPanel">
<h2>Einstellungen</h2>
<div class="form-grid">
<div class="form-group">
<label for="distance">Kegelabstand (m)</label>
<input type="number" id="distance" value="20" min="1" step="1">
</div>
<div class="form-group">
<label for="startSpeed">Startgeschwindigkeit (km/h)</label>
<input type="number" id="startSpeed" value="7" min="1" step="0.5">
</div>
<div class="form-group">
<label for="speedIncrement">Steigerung (km/h)</label>
<input type="number" id="speedIncrement" value="1" min="0.5" step="0.5">
</div>
<div class="form-group">
<label for="intervalMinutes">Intervall (Minuten)</label>
<input type="number" id="intervalMinutes" value="1" min="1" step="1">
</div>
</div>
<div class="info-display">
<span class="info-label">Zeit pro Kegel (Start):</span>
<span class="info-value" id="lapTimeInfo">10.29 s</span>
</div>
<button type="button" id="showForecastBtn" class="btn-forecast">Graph anzeigen</button>
<div class="forecast-panel" id="forecastPanel">
<div class="forecast-header">
<h3>Prognose</h3>
<button type="button" id="closeForecastBtn" class="btn-close">&times;</button>
</div>
<canvas id="forecastGraph" width="500" height="300"></canvas>
<div class="forecast-legend">
<span><span class="legend-dot speed"></span> Geschwindigkeit (km/h)</span>
<span><span class="legend-dot time"></span> Zeit pro Kegel (s)</span>
</div>
</div>
<div class="audio-controls">
<div class="sound-select">
<label for="beepSound">Signalton</label>
<select id="beepSound">
<option value="sharp">Scharf (empfohlen)</option>
<option value="normal">Normal</option>
<option value="low">Tief</option>
</select>
</div>
<div class="volume-control">
<label for="volume">Lautstärke</label>
<input type="range" id="volume" min="0" max="100" value="50">
<span id="volumeValue">50%</span>
</div>
<div class="toggle-control">
<label for="guidance">Guidance</label>
<label class="toggle-switch">
<input type="checkbox" id="guidance">
<span class="toggle-slider"></span>
</label>
<span class="toggle-hint">Countdown vor Piepton</span>
</div>
<div class="toggle-control">
<label for="doubleBeep">Double Beep</label>
<label class="toggle-switch">
<input type="checkbox" id="doubleBeep">
<span class="toggle-slider"></span>
</label>
<span class="toggle-hint">Bei Steigerung</span>
</div>
<div class="toggle-control">
<label for="showLog">Protokoll</label>
<label class="toggle-switch">
<input type="checkbox" id="showLog">
<span class="toggle-slider"></span>
</label>
<span class="toggle-hint">Detailliertes Log anzeigen</span>
</div>
</div>
<button id="startStopBtn" class="btn-start">Start</button>
</div>
<div class="display-panel" id="displayPanel">
<div class="stat-card primary">
<span class="stat-label">Gesamtlaufzeit</span>
<span class="stat-value" id="totalTime">00:00</span>
</div>
<div class="stat-card visual-card">
<svg id="trackVisual" viewBox="0 0 300 80" class="track-visual">
<!-- Left cone -->
<polygon id="coneLeft" points="30,65 40,30 50,65" fill="#f59e0b" stroke="#d97706" stroke-width="2"/>
<ellipse cx="40" cy="65" rx="12" ry="4" fill="#d97706"/>
<!-- Right cone -->
<polygon id="coneRight" points="250,65 260,30 270,65" fill="#f59e0b" stroke="#d97706" stroke-width="2"/>
<ellipse cx="260" cy="65" rx="12" ry="4" fill="#d97706"/>
<!-- Track line -->
<line x1="55" y1="55" x2="245" y2="55" stroke="rgba(255,255,255,0.2)" stroke-width="3" stroke-linecap="round"/>
<!-- Progress line -->
<line id="progressLine" x1="55" y1="55" x2="55" y2="55" stroke="#22c55e" stroke-width="4" stroke-linecap="round"/>
<!-- Runner dot -->
<circle id="runnerDot" cx="55" cy="55" r="8" fill="#3b82f6"/>
</svg>
</div>
<div class="stat-card">
<span class="stat-label">Aktuelle Geschwindigkeit</span>
<span class="stat-value" id="currentSpeed">7.0 <small>km/h</small></span>
</div>
<div class="stat-card">
<span class="stat-label">Nächste Steigerung in</span>
<span class="stat-value" id="nextIncrease">03:00</span>
</div>
<div class="stat-card">
<span class="stat-label">Nächster Piepton in</span>
<span class="stat-value" id="nextBeep">--</span>
</div>
<div class="stat-card">
<span class="stat-label">Zeit pro Kegel</span>
<span class="stat-value" id="lapTime">-- <small>s</small></span>
</div>
</div>
</div>
<div class="log-panel" id="logPanel">
<div class="log-title-row">
<h3>Protokoll</h3>
<button id="downloadCsvBtn" class="btn-download" style="display: none;">CSV Download</button>
</div>
<div class="log-header">
<span>Zeit</span>
<span>Ereignis</span>
<span>Geschw.</span>
<span>Kegel-Zeit</span>
<span>Distanz</span>
</div>
<div class="log-entries" id="logEntries">
<!-- Log entries will be added here -->
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

829
style.css Normal file
View File

@@ -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;
}
}