Popular Locations:
Analyzing Evaporative Drying Physics...
Fetching local weather models from Open-Meteo...
Unable to fetch forecast data
Please check your query or connection and try again.
--
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}
`;
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}
`;
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');
}
}
${h.dryingScore}
SCORE
Temp${formatTemp(h.tempC)}
Hum${h.humidity}%
Wind${formatWind(h.windKm)}
Rain${h.rainProb}%
${d.dryingScore}
AVG SCORE
Max Temp${formatTemp(d.maxTemp)}
Hum${d.avgHum}%
Max Wind${formatWind(d.windKm)}
Rain Prob${d.rainProb}%