Upload files to "/"
This commit is contained in:
749
app.js
Normal file
749
app.js
Normal 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();
|
||||
Reference in New Issue
Block a user