750 lines
23 KiB
JavaScript
750 lines
23 KiB
JavaScript
// 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();
|