DryOutdoors.com

Popular Locations:
--
Checking...

Calculating Drying Potential...

Analyzing temperature, relative humidity, wind speed, solar radiation, and rain risk.

Optimal Drying Window: --
Est. Drying Time: --
24-Hour Max Drying Score
0 out of 100
Based on Mixed Load

Avg Temp

--

Avg Dew Point

--

Avg Humidity

--

Wind Speed

--

Max Rain Risk

--

24-Hour Drying Timeline

Higher drying score (%) means faster evaporative moisture loss.

Fast Moderate Poor / Rain
Relative Humidity (%)

What does Humidity mean for drying?

Humidity measures how saturated the air is. Ideal line drying requires humidity under 60%. Above 75%, moisture cannot easily evaporate from fabric.

Hourly Breakdown

Scroll right for full window →

Why Line Dry Outdoors with DryOutdoors.com?

Humidity & Wind Physics
Relative humidity below 60% allows moisture to escape cloth fibers. A steady breeze (>10 km/h) strips away the damp micro-climate surrounding hung clothes, cutting dry time by half.
function calculateDewPoint(tempC, humidity) { const a = 17.27; const b = 237.7; const alpha = ((a * tempC) / (b + tempC)) + Math.log(humidity / 100.0); const dewPoint = (b * alpha) / (a - alpha); return Math.round(dewPoint * 10) / 10; } // --- REFACTORED VAPOR PRESSURE DEFICIT (VPD) MULTIPLICATIVE MODEL --- function calculateEvaporativeScoreMultiplicative(tempC, rh, windKm, cloud, rainProb) { if (rainProb > 40) { return { score: Math.max(5, Math.round(100 - rainProb)), status: "Rain Risk" }; } const dewPoint = calculateDewPoint(tempC, rh); const spread = tempC - dewPoint; // 1. Vapor Pressure Deficit (VPD) - Thermodynamic Evaporative Potential Gatekeeper // Saturated Vapor Pressure (e_s) & Actual Vapor Pressure (e_a) using Clausius-Clapeyron equation const es = 0.61078 * Math.exp((17.27 * tempC) / (tempC + 237.3)); // kPa const ea = es * (rh / 100); // kPa const vpd = es - ea; // kPa (Vapor Pressure Deficit) let vpdFactor = 1.0; if (vpd < 0.15) vpdFactor = 0.05; // Extreme saturation / Fog / Zero evaporation else if (vpd < 0.35) vpdFactor = 0.35; // Sluggish moisture transfer else if (vpd < 0.70) vpdFactor = 0.75; // Moderate drying rate else vpdFactor = 1.0; // High evaporative pull (>0.7 kPa) // 2. Relative Humidity Multiplier (Backstop limit) let humidityFactor = 1.0; if (rh > 85) humidityFactor = 0.15; else if (rh > 75) humidityFactor = 0.45; else if (rh > 60) humidityFactor = 0.80; else humidityFactor = 1.0; // 3. Wind Speed Multiplier const windMph = windKm * 0.621371; let windFactor = 1.0; if (windMph < 4) windFactor = 0.60; else if (windMph >= 8 && windMph <= 18) windFactor = 1.0; else if (windMph > 18) windFactor = 0.85; else windFactor = 0.80; // 4. Temperature Kinetic Energy Factor let tempFactor = 1.0; if (tempC < 4) tempFactor = 0.50; else if (tempC < 10) tempFactor = 0.75; else if (tempC < 18) tempFactor = 0.90; else tempFactor = 1.0; // Combined Multiplicative Product const rawScore = 100 * vpdFactor * humidityFactor * windFactor * tempFactor; const score = Math.min(100, Math.max(5, Math.round(rawScore))); let status = "Fair"; if (score >= 75) status = "Fast Dry"; else if (score >= 50) status = "Moderate"; else if (score >= 25) status = "Slow"; else status = "Unfavorable"; return { score, status, vpd: Math.round(vpd * 100) / 100 }; } } function toggleTheme() { const isDark = document.documentElement.classList.contains('dark'); if (isDark) { document.documentElement.classList.remove('dark'); localStorage.setItem('theme', 'light'); setThemeIcon(false); } else { document.documentElement.classList.add('dark'); localStorage.setItem('theme', 'dark'); setThemeIcon(true); } if (state.rawForecast) renderDashboard(); } function setThemeIcon(isDark) { const icon = document.getElementById('themeIcon'); if (icon) { icon.setAttribute('data-lucide', isDark ? 'sun' : 'moon'); lucide.createIcons(); } } async function fetchWeatherData(lat, lon, locationName) { showLoading(true); showError(false); try { const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=temperature_2m,relative_humidity_2m,precipitation_probability,wind_speed_10m,cloud_cover,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max&forecast_days=7&timezone=auto`; const response = await fetch(url); if (!response.ok) throw new Error('Failed to fetch weather forecast'); const data = await response.json(); state.rawForecast = data; state.hourlyData = processHourlyData(data); state.dailyData = processDailyData(data, state.hourlyData); state.locationName = locationName; state.lat = lat; state.lon = lon; renderDashboard(); showLoading(false); } catch (err) { console.error(err); showLoading(false); showError(true, err.message || 'Error processing weather data.'); } } function processHourlyData(data) { const hourly = data.hourly; const now = new Date(); const currentHourISO = now.toISOString().slice(0, 13) + ":00"; let startIndex = hourly.time.findIndex(t => t >= currentHourISO); if (startIndex === -1) startIndex = 0; const sliceEnd = Math.min(startIndex + 24, hourly.time.length); const hours = []; for (let i = startIndex; i < sliceEnd; i++) { const timeStr = hourly.time[i]; const tempC = hourly.temperature_2m[i]; const humidity = hourly.relative_humidity_2m[i]; const windKm = hourly.wind_speed_10m[i]; const cloud = hourly.cloud_cover[i]; const rainProb = hourly.precipitation_probability[i]; const dryingScore = calculateEvaporativeScore(tempC, humidity, windKm, cloud, rainProb); hours.push({ time: new Date(timeStr), tempC, humidity, windKm, cloud, rainProb, dryingScore }); } return hours; } function processDailyData(data, processedHours) { const daily = data.daily; const days = []; for (let i = 0; i < daily.time.length; i++) { const dateStr = daily.time[i]; const maxTemp = daily.temperature_2m_max[i]; const minTemp = daily.temperature_2m_min[i]; const rainProb = daily.precipitation_probability_max[i]; const windKm = daily.wind_speed_10m_max[i]; const dayDateISO = dateStr; const matchingHours = data.hourly.time.map((t, idx) => ({ time: t, temp: data.hourly.temperature_2m[idx], hum: data.hourly.relative_humidity_2m[idx], wind: data.hourly.wind_speed_10m[idx], cloud: data.hourly.cloud_cover[idx], rain: data.hourly.precipitation_probability[idx] })).filter(h => h.time.startsWith(dayDateISO)); let avgScore = 0; if (matchingHours.length > 0) { const scores = matchingHours.map(h => calculateEvaporativeScore(h.temp, h.hum, h.wind, h.cloud, h.rain)); avgScore = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length); } const avgHum = matchingHours.length > 0 ? Math.round(matchingHours.reduce((a, b) => a + b.hum, 0) / matchingHours.length) : 60; days.push({ date: new Date(dateStr), maxTemp, minTemp, rainProb, windKm, avgHum, dryingScore: avgScore }); } return days; } function calculateEvaporativeScore(tempC, rh, windKm, cloud, rainProb) { if (rainProb >= 60) return Math.max(0, 15 - Math.round(rainProb / 4)); let rhFactor = rh <= 40 ? 100 : (rh >= 90 ? 0 : 100 - ((rh - 40) / 50) * 100); let windFactor = windKm >= 20 ? 100 : (windKm / 20) * 100; let sunFactor = 100 - (cloud * 0.5); let tempFactor = Math.min(100, Math.max(10, (tempC / 25) * 100)); let score = (rhFactor * 0.35) + (windFactor * 0.30) + (sunFactor * 0.20) + (tempFactor * 0.15); if (rainProb > 20) score *= (1 - (rainProb - 20) / 100); return Math.min(100, Math.max(0, Math.round(score))); } function renderDashboard() { document.getElementById('locationLabel').textContent = state.locationName; if (state.viewMode === '24h') { const analysis = analyze24HourWindow(state.hourlyData); updateVerdictCard(analysis, '24h'); const avgTemp = state.hourlyData.reduce((acc, h) => acc + h.tempC, 0) / state.hourlyData.length; const avgHum = state.hourlyData.reduce((acc, h) => acc + h.humidity, 0) / state.hourlyData.length; const avgWind = state.hourlyData.reduce((acc, h) => acc + h.windKm, 0) / state.hourlyData.length; const maxRain = Math.max(...state.hourlyData.map(h => h.rainProb)); document.getElementById('metricTemp').textContent = formatTemp(avgTemp); document.getElementById('metricHumidity').textContent = `${Math.round(avgHum)}%`; document.getElementById('metricWind').textContent = formatWind(avgWind); document.getElementById('metricRain').textContent = `${maxRain}%`; renderHourlyChart(state.hourlyData); renderHourlyCardsTrack(state.hourlyData); } else { const analysis = analyze7DayOutlook(state.dailyData); updateVerdictCard(analysis, '7d'); const avgTemp = state.dailyData.reduce((acc, d) => acc + d.maxTemp, 0) / state.dailyData.length; const avgHum = state.dailyData.reduce((acc, d) => acc + d.avgHum, 0) / state.dailyData.length; const avgWind = state.dailyData.reduce((acc, d) => acc + d.windKm, 0) / state.dailyData.length; const maxRain = Math.max(...state.dailyData.map(d => d.rainProb)); document.getElementById('metricTemp').textContent = formatTemp(avgTemp); document.getElementById('metricHumidity').textContent = `${Math.round(avgHum)}%`; document.getElementById('metricWind').textContent = formatWind(avgWind); document.getElementById('metricRain').textContent = `${maxRain}%`; renderDailyChart(state.dailyData); renderDailyCardsTrack(state.dailyData); } lucide.createIcons(); } function analyze24HourWindow(hours) { let peakScore = Math.max(...hours.map(h => h.dryingScore)); let maxScore = 0; let bestStartIndex = -1; for (let i = 0; i <= hours.length - 4; i++) { let windowAvg = 0; let hasRain = false; for (let j = 0; j < 4; j++) { windowAvg += hours[i + j].dryingScore; if (hours[i + j].rainProb > 40) hasRain = true; } windowAvg /= 4; if (hasRain) windowAvg *= 0.4; if (windowAvg > maxScore) { maxScore = windowAvg; bestStartIndex = i; } } let bestWindowStr = "No ideal window"; if (bestStartIndex !== -1 && maxScore > 30) { bestWindowStr = `${formatTime(hours[bestStartIndex].time)} - ${formatTime(hours[Math.min(hours.length - 1, bestStartIndex + 4)].time)}`; } const baseTime = FABRIC_BASE_TIMES[state.fabricType]; let estimatedHours = (peakScore >= 75 ? baseTime : peakScore >= 55 ? baseTime * 1.4 : peakScore >= 35 ? baseTime * 2.2 : baseTime * 3.5).toFixed(1); return { peakScore, bestWindowStr, estimatedHours }; } function analyze7DayOutlook(days) { let peakScore = Math.max(...days.map(d => d.dryingScore)); let bestDayIndex = days.findIndex(d => d.dryingScore === peakScore); let bestWindowStr = "N/A"; if (bestDayIndex !== -1) { bestWindowStr = days[bestDayIndex].date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); } const baseTime = FABRIC_BASE_TIMES[state.fabricType]; let estimatedHours = (peakScore >= 75 ? baseTime : peakScore >= 55 ? baseTime * 1.4 : peakScore >= 35 ? baseTime * 2.2 : baseTime * 3.5).toFixed(1); return { peakScore, bestWindowStr, estimatedHours }; } function updateVerdictCard(analysis, mode) { const card = document.getElementById('verdictCard'); const badge = document.getElementById('verdictBadge'); const title = document.getElementById('verdictTitle'); const subtitle = document.getElementById('verdictSubtitle'); const bestWindowVal = document.getElementById('bestWindowVal'); const estTimeVal = document.getElementById('estTimeVal'); const scoreVal = document.getElementById('dryingScoreVal'); const scoreRing = document.getElementById('scoreRing'); const gaugeTitle = document.getElementById('scoreGaugeTitle'); const score = analysis.peakScore; scoreVal.textContent = score; const circumference = 364.4; scoreRing.style.strokeDashoffset = circumference - (score / 100) * circumference; gaugeTitle.textContent = mode === '24h' ? "24-Hour Peak Score" : "7-Day Best Score"; bestWindowVal.textContent = analysis.bestWindowStr; estTimeVal.textContent = score > 25 ? `~${analysis.estimatedHours} hrs` : "Unlikely to dry"; if (score >= 70) { // Explicit Teal/Sky gradient for high scores card.style.background = 'linear-gradient(135deg, #0d9488 0%, #14b8a6 50%, #0284c7 100%)'; badge.textContent = "Great Drying Weather!"; badge.className = "inline-block text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-emerald-100 text-emerald-900 shadow-xs mb-2"; title.textContent = mode === '24h' ? "Ideal Outdoor Line Drying Conditions!" : "Excellent Week Ahead for Laundry!"; subtitle.textContent = "Low relative humidity and strong evaporation scores make this window perfect for hanging clothes outside."; } else if (score >= 45) { // Explicit Warm Amber/Orange gradient for moderate scores card.style.background = 'linear-gradient(135deg, #f59e0b 0%, #d97706 50%, #ea580c 100%)'; badge.textContent = "Moderate Conditions"; badge.className = "inline-block text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-amber-100 text-amber-900 shadow-xs mb-2"; title.textContent = "Will Dry, But Need More Time"; subtitle.textContent = "Moderate breeze or cooler temps mean slower evaporation. Hang clothes out early in the optimal window."; } else { // Explicit Slate/Crimson gradient for poor scores card.style.background = 'linear-gradient(135deg, #334155 0%, #1e293b 50%, #0f172a 100%)'; badge.textContent = "Dry Indoors / Use Dryer"; badge.className = "inline-block text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-rose-100 text-rose-900 shadow-xs mb-2"; title.textContent = "Poor Outdoor Drying Weather"; subtitle.textContent = "High humidity or rain risk will keep laundry damp. Outdoor line drying is not recommended."; } } function renderHourlyChart(hours) { document.getElementById('chartSectionTitle').textContent = "24-Hour Drying Index Timeline"; const ctx = document.getElementById('dryingChart').getContext('2d'); const isDark = document.documentElement.classList.contains('dark'); const textColor = isDark ? '#94a3b8' : '#64748b'; const gridColor = isDark ? '#334155' : '#f1f5f9'; const labels = hours.map(h => formatTime(h.time)); const scores = hours.map(h => h.dryingScore); const humidities = hours.map(h => h.humidity); const barColors = hours.map(h => h.rainProb >= 50 ? 'rgba(239, 68, 68, 0.85)' : h.dryingScore >= 70 ? 'rgba(20, 184, 166, 0.85)' : h.dryingScore >= 45 ? 'rgba(251, 191, 36, 0.85)' : 'rgba(244, 63, 94, 0.75)'); if (state.chartInstance) state.chartInstance.destroy(); state.chartInstance = new Chart(ctx, { type: 'bar', data: { labels, datasets: [ { label: 'Drying Score (%)', data: scores, backgroundColor: barColors, borderRadius: 6, yAxisID: 'y' }, { label: 'Humidity (%)', data: humidities, type: 'line', borderColor: 'rgba(56, 189, 248, 0.9)', borderWidth: 2.5, pointRadius: 0, tension: 0.3, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { font: { size: 10 }, color: textColor } }, y: { min: 0, max: 100, ticks: { callback: v => v + '%', color: textColor }, grid: { color: gridColor } }, y1: { min: 0, max: 100, display: false } } } }); } function renderDailyChart(days) { document.getElementById('chartSectionTitle').textContent = "7-Day Drying Outlook"; const ctx = document.getElementById('dryingChart').getContext('2d'); const isDark = document.documentElement.classList.contains('dark'); const textColor = isDark ? '#94a3b8' : '#64748b'; const gridColor = isDark ? '#334155' : '#f1f5f9'; const labels = days.map(d => d.date.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })); const scores = days.map(d => d.dryingScore); const humidities = days.map(d => d.avgHum); const barColors = days.map(d => d.rainProb >= 50 ? 'rgba(239, 68, 68, 0.85)' : d.dryingScore >= 70 ? 'rgba(20, 184, 166, 0.85)' : d.dryingScore >= 45 ? 'rgba(251, 191, 36, 0.85)' : 'rgba(244, 63, 94, 0.75)'); if (state.chartInstance) state.chartInstance.destroy(); state.chartInstance = new Chart(ctx, { type: 'bar', data: { labels, datasets: [ { label: 'Drying Score (%)', data: scores, backgroundColor: barColors, borderRadius: 6, yAxisID: 'y' }, { label: 'Avg Humidity (%)', data: humidities, type: 'line', borderColor: 'rgba(56, 189, 248, 0.9)', borderWidth: 2.5, pointRadius: 2, tension: 0.3, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { font: { size: 10 }, color: textColor } }, y: { min: 0, max: 100, ticks: { callback: v => v + '%', color: textColor }, grid: { color: gridColor } }, y1: { min: 0, max: 100, display: false } } } }); } function renderHourlyCardsTrack(hours) { document.getElementById('timelineCardsTitle').textContent = "Hourly Breakdown"; const track = document.getElementById('timelineCardsTrack'); track.innerHTML = ''; hours.forEach(h => { let badgeBg = 'bg-rose-100 text-rose-800 dark:bg-rose-950/60 dark:text-rose-300'; let badgeText = 'Poor'; if (h.rainProb >= 50) { badgeBg = 'bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-300'; badgeText = 'Rain Risk'; } else if (h.dryingScore >= 70) { badgeBg = 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300'; badgeText = 'Fast'; } else if (h.dryingScore >= 45) { badgeBg = 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300'; badgeText = 'Moderate'; } const card = document.createElement('div'); card.className = "min-w-[130px] glass-card p-3.5 rounded-2xl flex flex-col justify-between items-center text-center snap-start shadow-xs"; card.innerHTML = ` ${formatTime(h.time)} ${badgeText}
${h.dryingScore} SCORE
Temp${formatTemp(h.tempC)}
Hum${h.humidity}%
Wind${formatWind(h.windKm)}
Rain${h.rainProb}%
`; track.appendChild(card); }); } function renderDailyCardsTrack(days) { document.getElementById('timelineCardsTitle').textContent = "7-Day Daily Forecast Cards"; const track = document.getElementById('timelineCardsTrack'); track.innerHTML = ''; days.forEach(d => { let badgeBg = 'bg-rose-100 text-rose-800 dark:bg-rose-950/60 dark:text-rose-300'; let badgeText = 'Poor'; if (d.rainProb >= 50) { badgeBg = 'bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-300'; badgeText = 'Rain Risk'; } else if (d.dryingScore >= 70) { badgeBg = 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300'; badgeText = 'Great Day'; } else if (d.dryingScore >= 45) { badgeBg = 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300'; badgeText = 'Fair'; } const card = document.createElement('div'); card.className = "min-w-[140px] glass-card p-3.5 rounded-2xl flex flex-col justify-between items-center text-center snap-start shadow-xs"; card.innerHTML = ` ${d.date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} ${badgeText}
${d.dryingScore} AVG SCORE
Max Temp${formatTemp(d.maxTemp)}
Hum${d.avgHum}%
Max Wind${formatWind(d.windKm)}
Rain Prob${d.rainProb}%
`; track.appendChild(card); }); } function switchViewMode(mode) { state.viewMode = mode; const b24 = document.getElementById('view24hBtn'); const b7d = document.getElementById('view7dBtn'); if (mode === '24h') { b24.className = "px-3 py-1.5 rounded-lg bg-white dark:bg-slate-700 shadow-xs text-brand-700 dark:text-brand-300 transition-all font-bold"; b7d.className = "px-3 py-1.5 rounded-lg text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all"; } else { b7d.className = "px-3 py-1.5 rounded-lg bg-white dark:bg-slate-700 shadow-xs text-brand-700 dark:text-brand-300 transition-all font-bold"; b24.className = "px-3 py-1.5 rounded-lg text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all"; } renderDashboard(); } function quickSearch(city) { document.getElementById('searchInput').value = city; async function searchLocationByName(query) { showLoading(true); showError(false); try { const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=1&language=en&format=json`; const res = await fetch(geoUrl); const geoData = await res.json(); if (!geoData.results || geoData.results.length === 0) { throw new Error(`Location "${query}" not found.`); } const result = geoData.results[0]; const displayName = `${result.name}${result.admin1 ? ', ' + result.admin1 : ''}${result.country ? ', ' + result.country : ''}`; // Safe history pushState execution for sandboxed iframes & blob origins try { const newUrl = `${window.location.pathname}?loc=${encodeURIComponent(result.name)}`; window.history.pushState({ path: newUrl }, '', newUrl); } catch (historyErr) { console.warn('History pushState skipped (sandboxed frame):', historyErr); } fetchWeatherData(result.latitude, result.longitude, displayName); } catch (err) { console.error(err); showLoading(false); showError(true, err.message || 'Error searching location.'); } } function getUserLocation() { if (!navigator.geolocation) { showError(true, 'Geolocation is not supported by your browser.'); return; } showLoading(true); showError(false); navigator.geolocation.getCurrentPosition( async (pos) => { fetchWeatherData(pos.coords.latitude, pos.coords.longitude, "Your Current Location"); }, () => { showLoading(false); showError(true, 'Location access denied or unavailable.'); }, { timeout: 10000 } ); } function setUnits(system) { state.unitSystem = system; const metricBtn = document.getElementById('unitMetricBtn'); const imperialBtn = document.getElementById('unitImperialBtn'); if (system === 'metric') { metricBtn.className = "px-2.5 py-1 rounded-lg bg-white dark:bg-slate-700 shadow-xs text-slate-800 dark:text-white transition-all"; imperialBtn.className = "px-2.5 py-1 rounded-lg text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all"; } else { imperialBtn.className = "px-2.5 py-1 rounded-lg bg-white dark:bg-slate-700 shadow-xs text-slate-800 dark:text-white transition-all"; metricBtn.className = "px-2.5 py-1 rounded-lg text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all"; } if (state.rawForecast) renderDashboard(); } function updateFabricType() { state.fabricType = document.getElementById('fabricSelect').value; if (state.rawForecast) renderDashboard(); } function formatTemp(tempC) { return state.unitSystem === 'imperial' ? `${Math.round((tempC * 9/5) + 32)}°F` : `${Math.round(tempC)}°C`; } function formatWind(windKm) { return state.unitSystem === 'imperial' ? `${Math.round(windKm * 0.621371)} mph` : `${Math.round(windKm)} km/h`; } function formatTime(dateObj) { return dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true }); } function showLoading(isLoading) { const loader = document.getElementById('loadingState'); const dash = document.getElementById('dashboardView'); if (isLoading) { loader.classList.remove('hidden'); dash.classList.add('opacity-40', 'pointer-events-none'); } else { loader.classList.add('hidden'); dash.classList.remove('opacity-40', 'pointer-events-none'); } } function showError(isError, message = '') { const errorBox = document.getElementById('errorState'); const errorMessage = document.getElementById('errorMessage'); if (isError) { errorMessage.textContent = message; errorBox.classList.remove('hidden'); } else { errorBox.classList.add('hidden'); } }