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();
|
||||||
155
index.html
Normal file
155
index.html
Normal 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">×</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
829
style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user