Enhance mobile UX: learn guide, alerts actions, and shareable weather cards

This commit is contained in:
Nate Emmert committed 2026-03-17 14:21:32 -04:00
1 parent 8f9a2d1c02
commit 950e2708ab
4 files changed
+1994 -145

No files matched your search

+495 -74
View File
@@ -89,9 +89,13 @@ const weatherDiv = document.getElementById('weather');
const unitsBtn = document.getElementById('units-btn');
const unitsBtnSettings = document.getElementById('units-btn-settings');
const shareBtn = document.getElementById('share-btn');
const shareHeroBtn = document.getElementById('share-hero-btn');
const shareWeatherCardBtn = document.getElementById('share-weather-card-btn');
const saveCurrentBtn = document.getElementById('save-current-btn');
const savedLocationsEl = document.getElementById('saved-locations');
const notifyBtn = document.getElementById('notify-btn');
const shareHeroStatusEl = document.getElementById('share-hero-status');
const shareWeatherStatusEl = document.getElementById('share-weather-status');
const autoDetectToggle = document.getElementById('auto-detect-toggle');
const alertsEmptyEl = document.getElementById('alerts-empty');
const settingsLocationEl = document.getElementById('settings-location');
@@ -353,7 +357,14 @@ function activateTab(tabName) {
setTimeout(() => { radarEmbed && radarEmbed.refresh && radarEmbed.refresh(); }, 60);
}
if (tabName === 'unified-map') {
setTimeout(() => { unifiedMap && unifiedMap.refresh && unifiedMap.refresh(); }, 50);
setTimeout(() => {
if (unifiedMap && unifiedMap.refresh) unifiedMap.refresh();
refreshMapsOverview();
}, 60);
setTimeout(() => {
if (unifiedMap && unifiedMap.refresh) unifiedMap.refresh();
refreshMapsOverview();
}, 340);
}
}
document.querySelectorAll('.tab, .bottom-tab').forEach(btn => {
@@ -500,6 +511,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
}
function frameTileUrl(frame) {
if (frame?.url) return frame.url;
if (mode === 'satellite') {
return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`;
}
@@ -513,7 +525,12 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
if (!frame) return;
if (!layers[mode][frame.time]) {
layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0, maxZoom: 17 });
const opts = {
opacity: 0,
maxZoom: 19,
};
if (frame?.maxNativeZoom != null) opts.maxNativeZoom = frame.maxNativeZoom;
layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), opts);
}
Object.values(layers.radar).forEach(l => {
@@ -617,9 +634,27 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
radarFrames = [];
satelliteFrames = [];
(data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false }));
(data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true }));
(data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false }));
(data.radar?.past || []).forEach(f => radarFrames.push({
time: f.time,
path: f.path,
url: f.url,
maxNativeZoom: f.maxNativeZoom,
isForecast: false,
}));
(data.radar?.nowcast || []).forEach(f => radarFrames.push({
time: f.time,
path: f.path,
url: f.url,
maxNativeZoom: f.maxNativeZoom,
isForecast: true,
}));
(data.satellite?.infrared || []).forEach(f => satelliteFrames.push({
time: f.time,
path: f.path,
url: f.url,
maxNativeZoom: f.maxNativeZoom,
isForecast: false,
}));
if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
if (!radarFrames.length && !satelliteFrames.length) {
@@ -768,7 +803,8 @@ function refreshMapsOverview() {
if (!state.currentCity) return;
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6);
const zoom = mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 6;
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, zoom || 6);
clearLightningMarkers();
clearWindArrows();
@@ -789,46 +825,7 @@ function renderLightningStrikes(city) {
if (!mapsOverview.map || !state.showLightning) return;
clearLightningMarkers();
// Simulate lightning strikes in the vicinity of the city
// In production, this would fetch from a real lightning API like Blitzortung
const strokeCount = 5 + Math.random() * 3; // Random 5-8 strikes
const maxRadius = 0.35;
for (let i = 0; i < strokeCount; i++) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * maxRadius;
const latOffset = Math.cos(angle) * radius;
const lonOffset = Math.sin(angle) * radius;
const lat = parseFloat(city.latitude) + latOffset;
const lon = parseFloat(city.longitude) + lonOffset;
const marker = L.circleMarker([lat, lon], {
radius: 3 + Math.random() * 3,
fillColor: '#ffeb3b',
fillOpacity: 0.7 + Math.random() * 0.3,
color: '#ff6f00',
weight: 1.5,
className: 'lightning-marker',
}).bindPopup('⚡ Lightning strike (simulated)');
marker.addTo(mapsOverview.map);
state.lightningMarkers.push(marker);
// Animate the marker to fade out
setTimeout(() => {
if (marker && mapsOverview.map.hasLayer(marker)) {
const fade = setInterval(() => {
const opacity = parseFloat(marker.options.fillOpacity) - 0.05;
if (opacity <= 0) {
clearInterval(fade);
mapsOverview.map.removeLayer(marker);
} else {
marker.setStyle({ fillOpacity: opacity });
}
}, 200);
}
}, 3000);
}
// Simulated strikes were removed. Only live strike feeds should render here.
}
function renderWindArrows(city) {
@@ -851,20 +848,20 @@ function renderWindArrows(city) {
if (windGustEl) windGustEl.textContent = windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--';
if (windDirectionEl) windDirectionEl.textContent = compassDirFull(windDir);
// Draw multiple wind barbs around the city center to show wind strength
// Draw a denser set of arrows around the city center for richer wind context.
const lat = parseFloat(city.latitude);
const lon = parseFloat(city.longitude);
const arrowCount = Math.min(Math.ceil(windSpeed / 5), 5); // More arrows = stronger wind
const baseRadius = 0.15;
const arrowCount = Math.max(3, Math.min(9, Math.ceil(windSpeed / 3))); // 3-9 arrows
const baseRadius = 0.12;
for (let i = 0; i < arrowCount; i++) {
const offset = (i - arrowCount / 2) * 0.08;
const offsetLat = lat + offset;
const offsetLon = lon;
const bandPos = i - (arrowCount - 1) / 2;
const offsetLat = lat + (bandPos * 0.045);
const offsetLon = lon + (Math.abs(bandPos) % 2 ? 0.025 : -0.025);
// Calculate arrow end point based on wind direction
const radians = (windDir * Math.PI) / 180;
const arrowLength = 0.15 + i * 0.05;
const arrowLength = baseRadius + (Math.abs(bandPos) * 0.028) + Math.min(windSpeed / 120, 0.08);
const endLat = offsetLat + arrowLength * Math.cos(radians);
const endLon = offsetLon + arrowLength * Math.sin(radians);
@@ -880,7 +877,9 @@ function renderWindArrows(city) {
opacity: 1.0,
smooth: true,
}
).bindPopup(`💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}<br>Direction: ${compassDirFull(windDir)}`);
).bindPopup(
`💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}<br>Gust: ${windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--'}<br>Direction: ${compassDirFull(windDir)} (${Math.round(windDir)}°)`
);
arrow.addTo(mapsOverview.map);
state.windArrows.push(arrow);
@@ -936,13 +935,22 @@ function renderAqiMarker(city) {
const aqi = state.currentAir.current.us_aqi;
if (aqi == null) return;
const color = aqi > 150 ? '#d32f2f' : aqi > 100 ? '#f57c00' : aqi > 50 ? '#fbc02d' : '#388e3c';
const marker = L.circleMarker([city.latitude, city.longitude], {
const aqiLat = Number(city.latitude) - 0.045;
const aqiLon = Number(city.longitude) - 0.06;
const marker = L.circleMarker([aqiLat, aqiLon], {
radius: 9,
fillColor: color,
fillOpacity: 0.85,
color: '#fff',
weight: 2,
}).bindPopup(`AQI ${aqi}`);
marker.bindTooltip(`AQI ${aqi}`, {
permanent: true,
direction: 'left',
offset: [-10, 0],
className: 'map-label map-label-aqi',
opacity: 0.95,
});
marker.addTo(mapsOverview.map);
state.aqiMarkers.push(marker);
}
@@ -965,13 +973,22 @@ function renderTempMarker(city) {
if (!mapsOverview.map || !state.unifiedMapLayers.temp || !state.currentWeather?.current) return;
clearTempMarker();
const temp = tempDisplay(state.currentWeather.current.temperature_2m);
const marker = L.circleMarker([city.latitude, city.longitude], {
const tempLat = Number(city.latitude) + 0.05;
const tempLon = Number(city.longitude) - 0.055;
const marker = L.circleMarker([tempLat, tempLon], {
radius: 11,
fillColor: '#1e88e5',
fillOpacity: 0.9,
color: '#fff',
weight: 2,
}).bindPopup(`${temp}°${tempUnit()}`);
marker.bindTooltip(`${temp}°${tempUnit()}`, {
permanent: true,
direction: 'right',
offset: [10, 0],
className: 'map-label map-label-temp',
opacity: 0.95,
});
marker.addTo(mapsOverview.map);
state.tempMarker = marker;
}
@@ -982,13 +999,22 @@ function renderSunMoonMarker(city) {
const daily = state.currentWeather.daily;
const sunrise = formatClock(daily.sunrise?.[0]);
const sunset = formatClock(daily.sunset?.[0]);
const marker = L.circleMarker([city.latitude, city.longitude], {
const smLat = Number(city.latitude) + 0.05;
const smLon = Number(city.longitude) + 0.06;
const marker = L.circleMarker([smLat, smLon], {
radius: 8,
fillColor: '#fbc02d',
fillOpacity: 0.8,
color: '#fff',
weight: 2,
}).bindPopup(`Sunrise: ${sunrise}<br>Sunset: ${sunset}`);
marker.bindTooltip(`☀ ${sunrise} • 🌙 ${sunset}`, {
permanent: true,
direction: 'top',
offset: [0, -10],
className: 'map-label map-label-sunmoon',
opacity: 0.95,
});
marker.addTo(mapsOverview.map);
state.sunMoonMarkers.push(marker);
}
@@ -1022,6 +1048,16 @@ if (unifiedMapEl && state.unifiedMapRadarMode === 'satellite') {
}
// ── Rendering ────────────────────────────────────────────────────────────────
function setMapOverlayReadouts({ temp, aqi, sunmoon } = {}) {
const tempEl = document.getElementById('overlay-temp');
const aqiEl = document.getElementById('overlay-aqi');
const sunmoonEl = document.getElementById('overlay-sunmoon');
if (tempEl && temp != null && temp !== '') tempEl.textContent = `Temp ${temp}`;
if (aqiEl && aqi != null && aqi !== '') aqiEl.textContent = `AQI ${aqi}`;
if (sunmoonEl && sunmoon) sunmoonEl.textContent = sunmoon;
}
function renderWeather(data, city) {
const c = data.current;
const h = data.hourly;
@@ -1058,12 +1094,14 @@ function renderWeather(data, city) {
const panelWindDir = document.getElementById('panel-wind-dir');
const panelTemp = document.getElementById('panel-temp');
const panelAqi = document.getElementById('panel-aqi');
const panelCloud = document.getElementById('panel-cloud');
const panelWrap = document.getElementById('data-panel');
if (panelLocation) panelLocation.textContent = cityLabel(city) || city.name || '--';
if (panelWindSpeed) panelWindSpeed.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()}`;
if (panelWindDir) panelWindDir.textContent = compassDirFull(c.wind_direction_10m || 0);
if (panelTemp) panelTemp.textContent = `${tempDisplay(c.temperature_2m)}°${tempUnit()}`;
if (panelAqi) panelAqi.textContent = state.currentAir?.current?.us_aqi ?? '--';
if (panelCloud) panelCloud.textContent = c.cloud_cover != null ? `${Math.round(c.cloud_cover)}%` : '--';
if (panelWrap) panelWrap.classList.remove('hidden');
document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0);
document.getElementById('cur-cloud').textContent = c.cloud_cover;
@@ -1074,6 +1112,13 @@ function renderWeather(data, city) {
document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]);
document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]);
const sunriseLabel = formatClock(d.sunrise[0]);
const sunsetLabel = formatClock(d.sunset[0]);
setMapOverlayReadouts({
temp: `${tempDisplay(c.temperature_2m)}°${tempUnit()}`,
sunmoon: `Sun ${sunriseLabel}/${sunsetLabel}`,
});
// Sun timeline progress
const sunriseMs = new Date(d.sunrise[0]).getTime();
const sunsetMs = new Date(d.sunset[0]).getTime();
@@ -1118,7 +1163,8 @@ function renderWeather(data, city) {
radarEmbed.panTo(city.latitude, city.longitude, 6);
radarFull.panTo(city.latitude, city.longitude, 6);
mapsOverview.panTo(city.latitude, city.longitude, 6);
const mapZoom = mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6;
mapsOverview.panTo(city.latitude, city.longitude, mapZoom || 6);
if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`;
updateSourceLocation(city);
updateShareUrl(city);
@@ -1177,6 +1223,234 @@ function renderAirQuality(data) {
const panelAqi = document.getElementById('panel-aqi');
if (panelAqi) panelAqi.textContent = cur.us_aqi ?? '--';
setMapOverlayReadouts({ aqi: cur.us_aqi ?? '--' });
}
function normalizeSeverity(severity) {
const s = String(severity || 'Unknown').toLowerCase();
if (s.includes('extreme')) return 'Extreme';
if (s.includes('severe')) return 'Severe';
if (s.includes('moderate')) return 'Moderate';
if (s.includes('minor')) return 'Minor';
return 'Unknown';
}
function severityRank(severity) {
const map = { Extreme: 0, Severe: 1, Moderate: 2, Minor: 3, Unknown: 4 };
return map[severity] ?? 4;
}
function plainAlertAction(eventName, severity) {
const eventText = String(eventName || '').toLowerCase();
const sev = normalizeSeverity(severity);
if (eventText.includes('tornado')) return 'Move to an interior room on the lowest level away from windows immediately.';
if (eventText.includes('thunderstorm')) return 'Pause outdoor activity, secure loose items, and be ready to shelter indoors quickly.';
if (eventText.includes('flood')) return 'Avoid low-water crossings and reroute away from flood-prone roads.';
if (eventText.includes('winter') || eventText.includes('snow') || eventText.includes('ice') || eventText.includes('blizzard')) return 'Delay travel if possible and prepare for slick roads and slower stopping distances.';
if (eventText.includes('heat')) return 'Hydrate often, reduce exertion, and seek shade or cooling when possible.';
if (eventText.includes('wind')) return 'Secure outdoor objects and use extra caution when driving high-profile vehicles.';
if (eventText.includes('fire') || eventText.includes('red flag')) return 'Avoid sparks or open flame sources and follow local fire restrictions.';
if (sev === 'Extreme' || sev === 'Severe') return 'Treat this as high risk and take protective action now.';
if (sev === 'Moderate') return 'Use caution and adjust plans to reduce exposure to hazardous conditions.';
return 'Stay aware of updates and keep backup plans ready.';
}
function buildAlertTiming(p) {
const start = p?.onset || p?.effective || p?.sent;
const end = p?.expires;
const startTxt = start ? formatClock(start) : 'now';
const endTxt = end ? formatClock(end) : 'until further notice';
return `${startTxt} - ${endTxt}`;
}
function buildStormEtaText() {
const nowMs = Date.now();
const alertTimes = (state.currentAlerts?.features || [])
.map(f => {
const p = f.properties || {};
return new Date(p.onset || p.effective || p.sent || 0).getTime();
})
.filter(ms => Number.isFinite(ms) && ms > nowMs)
.sort((a, b) => a - b);
if (alertTimes.length) {
const mins = Math.max(1, Math.round((alertTimes[0] - nowMs) / 60000));
if (mins < 60) return `~${mins} min`;
return `~${Math.round(mins / 60)} hr`;
}
const h = state.currentWeather?.hourly;
if (!h?.time || !h?.weather_code) return 'No storm signal in next 24h';
for (let i = 0; i < h.time.length && i < 24; i++) {
const ts = new Date(h.time[i]).getTime();
if (ts < nowMs) continue;
if (Number(h.weather_code[i]) >= 95) {
const mins = Math.max(1, Math.round((ts - nowMs) / 60000));
return mins < 60 ? `~${mins} min` : `~${Math.round(mins / 60)} hr`;
}
}
return 'No storm signal in next 24h';
}
function computeRiskLabel() {
const severities = (state.currentAlerts?.features || []).map(f => normalizeSeverity(f.properties?.severity));
if (severities.some(s => s === 'Extreme' || s === 'Severe')) return 'High';
if (severities.some(s => s === 'Moderate')) return 'Elevated';
if (severities.some(s => s === 'Minor')) return 'Guarded';
return 'Low';
}
function buildShareChecklist(risk) {
if (risk === 'High') return ['Have shelter plan ready', 'Delay non-essential travel', 'Keep devices fully charged'];
if (risk === 'Elevated') return ['Monitor radar and alerts hourly', 'Adjust outdoor timing', 'Secure loose outdoor items'];
if (risk === 'Guarded') return ['Use caution for outdoor plans', 'Keep backup indoor option', 'Recheck before departure'];
return ['Good conditions overall', 'Check updates before longer trips', 'Carry normal weather gear'];
}
function buildWeatherCardText() {
const city = cityLabel(state.currentCity) || 'Selected location';
const c = state.currentWeather?.current || {};
const currentAqi = state.currentAir?.current?.us_aqi ?? '--';
const risk = computeRiskLabel();
const eta = buildStormEtaText();
const alerts = state.currentAlerts?.features || [];
const checklist = buildShareChecklist(risk);
const lines = [
`Weather Card: ${city}`,
`Temp: ${tempDisplay(c.temperature_2m)}°${tempUnit()} (Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()})`,
`Wind: ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`,
`AQI: ${currentAqi}`,
`Storm ETA: ${eta}`,
`Risk Level: ${risk}`,
`Active Alerts: ${alerts.length}`,
'Checklist:',
...checklist.map(item => `- ${item}`),
];
return lines.join('\n');
}
function buildHeroCardText() {
const city = cityLabel(state.currentCity) || 'Selected location';
const c = state.currentWeather?.current || {};
const w = wmo(c.weather_code);
const risk = computeRiskLabel();
const currentAqi = state.currentAir?.current?.us_aqi ?? '--';
return [
`Now in ${city}`,
`${w.icon} ${w.desc}`,
`Temp ${tempDisplay(c.temperature_2m)}°${tempUnit()} (Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()})`,
`High ${tempDisplay(state.currentWeather?.daily?.temperature_2m_max?.[0])}°${tempUnit()} / Low ${tempDisplay(state.currentWeather?.daily?.temperature_2m_min?.[0])}°${tempUnit()}`,
`Wind ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`,
`AQI ${currentAqi} | Risk ${risk}`,
].join('\n');
}
function drawRoundedRect(ctx, x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
function canvasToPngBlob(canvas) {
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob), 'image/png');
});
}
async function buildHeroCardImageFile() {
const city = cityLabel(state.currentCity) || 'Selected location';
const c = state.currentWeather?.current || {};
const d = state.currentWeather?.daily || {};
const w = wmo(c.weather_code);
const currentAqi = state.currentAir?.current?.us_aqi ?? '--';
const risk = computeRiskLabel();
const canvas = document.createElement('canvas');
canvas.width = 1080;
canvas.height = 1350;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const bg = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
bg.addColorStop(0, '#1f2a38');
bg.addColorStop(1, '#334e68');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
drawRoundedRect(ctx, 56, 56, canvas.width - 112, canvas.height - 112, 38);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '700 68px Segoe UI';
ctx.fillText('Weather Hero Card', 96, 156);
ctx.font = '600 42px Segoe UI';
ctx.fillStyle = '#dbeafe';
ctx.fillText(city, 96, 222);
ctx.font = '600 120px Segoe UI Emoji, Segoe UI';
ctx.fillStyle = '#ffffff';
ctx.fillText(w.icon || '⛅', 96, 390);
ctx.font = '700 64px Segoe UI';
ctx.fillText(`${tempDisplay(c.temperature_2m)}°${tempUnit()}`, 250, 362);
ctx.font = '500 36px Segoe UI';
ctx.fillStyle = '#e2e8f0';
ctx.fillText(`${w.desc}`, 250, 412);
ctx.fillText(`Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`, 250, 458);
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
drawRoundedRect(ctx, 96, 520, canvas.width - 192, 460, 28);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '600 36px Segoe UI';
ctx.fillText(`High / Low: ${tempDisplay(d.temperature_2m_max?.[0])}°${tempUnit()} / ${tempDisplay(d.temperature_2m_min?.[0])}°${tempUnit()}`, 132, 610);
ctx.fillText(`Wind: ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`, 132, 684);
ctx.fillText(`AQI: ${currentAqi}`, 132, 758);
ctx.fillText(`Risk level: ${risk}`, 132, 832);
ctx.fillText(`Shared: ${new Date().toLocaleString()}`, 132, 906);
ctx.fillStyle = '#cbd5e1';
ctx.font = '500 30px Segoe UI';
ctx.fillText('Open full details and alerts in app link below.', 96, 1080);
ctx.fillText(window.location.href, 96, 1130);
const blob = await canvasToPngBlob(canvas);
if (!blob) return null;
return new File([blob], `hero-weather-${Date.now()}.png`, { type: 'image/png' });
}
function showHeroShareStatus(msg) {
if (!shareHeroStatusEl) return;
shareHeroStatusEl.textContent = msg;
show(shareHeroStatusEl);
setTimeout(() => {
shareHeroStatusEl.textContent = '';
hide(shareHeroStatusEl);
}, 2200);
}
function showShareWeatherStatus(msg) {
if (!shareWeatherStatusEl) return;
shareWeatherStatusEl.textContent = msg;
show(shareWeatherStatusEl);
setTimeout(() => {
shareWeatherStatusEl.textContent = '';
hide(shareWeatherStatusEl);
}, 2200);
}
function renderAlerts(data) {
@@ -1193,12 +1467,67 @@ function renderAlerts(data) {
if (alertsEmptyEl) hide(alertsEmptyEl);
features.slice(0, 5).forEach(f => {
const grouped = new Map();
features.forEach(f => {
const p = f.properties || {};
const item = document.createElement('div');
item.className = 'alert-item';
item.innerHTML = `<strong>${p.event || 'Alert'}</strong><span>${p.severity || 'Unknown severity'}</span><span>${p.headline || p.description || ''}</span>`;
list.appendChild(item);
const sev = normalizeSeverity(p.severity);
if (!grouped.has(sev)) grouped.set(sev, []);
grouped.get(sev).push(f);
});
Array.from(grouped.keys())
.sort((a, b) => severityRank(a) - severityRank(b))
.forEach(severity => {
const groupWrap = document.createElement('section');
groupWrap.className = `alerts-group alerts-group-${severity.toLowerCase()}`;
const head = document.createElement('div');
head.className = 'alerts-group-head';
const title = document.createElement('h5');
title.className = 'alerts-group-title';
title.textContent = `${severity} Alerts (${grouped.get(severity).length})`;
const hint = document.createElement('span');
hint.className = 'alerts-group-hint';
hint.textContent = severityRank(severity) <= 1 ? 'Take action now' : 'Use caution';
head.appendChild(title);
head.appendChild(hint);
groupWrap.appendChild(head);
grouped.get(severity).slice(0, 6).forEach(f => {
const p = f.properties || {};
const card = document.createElement('article');
card.className = 'alert-item';
const top = document.createElement('div');
top.className = 'alert-top';
const event = document.createElement('strong');
event.textContent = p.event || 'Alert';
const sev = document.createElement('span');
sev.className = `alert-severity severity-${normalizeSeverity(p.severity).toLowerCase()}`;
sev.textContent = normalizeSeverity(p.severity);
top.appendChild(event);
top.appendChild(sev);
const timing = document.createElement('div');
timing.className = 'alert-timing';
timing.textContent = `Window: ${buildAlertTiming(p)}`;
const msg = document.createElement('div');
msg.className = 'alert-message';
msg.textContent = p.headline || p.description || 'See official sources for details.';
const action = document.createElement('div');
action.className = 'alert-action';
action.textContent = `What this means for me: ${plainAlertAction(p.event, p.severity)}`;
card.appendChild(top);
card.appendChild(timing);
card.appendChild(msg);
card.appendChild(action);
groupWrap.appendChild(card);
});
list.appendChild(groupWrap);
});
show(wrap);
@@ -1428,6 +1757,73 @@ shareBtn.addEventListener('click', async () => {
}
});
if (shareWeatherCardBtn) {
shareWeatherCardBtn.addEventListener('click', async () => {
if (!state.currentCity || !state.currentWeather) {
showErr('Load a location first to share a weather card.');
return;
}
const text = buildWeatherCardText();
const payload = {
title: `Weather card: ${cityLabel(state.currentCity) || 'Location'}`,
text,
url: window.location.href,
};
try {
if (navigator.share) {
await navigator.share(payload);
showShareWeatherStatus('Shared weather card');
return;
}
await navigator.clipboard.writeText(`${text}\n\n${window.location.href}`);
showShareWeatherStatus('Weather card copied');
} catch (err) {
if (err && err.name === 'AbortError') return;
showErr('Could not share weather card.');
}
});
}
if (shareHeroBtn) {
shareHeroBtn.addEventListener('click', async () => {
if (!state.currentCity || !state.currentWeather) {
showErr('Load a location first to share hero conditions.');
return;
}
const text = buildHeroCardText();
const title = `Now: ${cityLabel(state.currentCity) || 'Location'}`;
try {
const imageFile = await buildHeroCardImageFile();
if (navigator.share && imageFile) {
const filePayload = { title, text, files: [imageFile] };
if (!navigator.canShare || navigator.canShare({ files: [imageFile] })) {
await navigator.share(filePayload);
showHeroShareStatus('Shared hero image');
return;
}
}
if (navigator.share) {
await navigator.share({ title, text, url: window.location.href });
showHeroShareStatus('Shared hero card');
return;
}
await navigator.clipboard.writeText(`${text}\n\n${window.location.href}`);
showHeroShareStatus('Hero card copied');
} catch (err) {
if (err && err.name === 'AbortError') return;
showErr('Could not share hero card.');
}
});
}
saveCurrentBtn.addEventListener('click', () => {
if (!state.currentCity) return;
const key = cityKey(state.currentCity);
@@ -1542,7 +1938,26 @@ function setRadarLayerVisible(visible) {
if (!mapsOverview?.map) return;
mapsOverview.map.eachLayer(layer => {
if (!layer?._url) return;
if (layer._url.includes('tilecache.rainviewer.com')) {
const isRadarLayer =
layer._url.includes('nexrad-n0q-900913') ||
layer._url.includes('tilecache.rainviewer.com') ||
layer._url.includes('radar_base_reflectivity_time');
if (isRadarLayer) {
if (visible && !mapsOverview.map.hasLayer(layer)) mapsOverview.map.addLayer(layer);
if (!visible && mapsOverview.map.hasLayer(layer)) mapsOverview.map.removeLayer(layer);
}
});
}
function setCloudLayerVisible(visible) {
if (!mapsOverview?.map) return;
mapsOverview.map.eachLayer(layer => {
if (!layer?._url) return;
const isCloudLayer =
layer._url.includes('goes_') ||
layer._url.includes('/0/0_0.png') ||
layer._url.includes('sat_meteo_emulated');
if (isCloudLayer) {
if (visible && !mapsOverview.map.hasLayer(layer)) mapsOverview.map.addLayer(layer);
if (!visible && mapsOverview.map.hasLayer(layer)) mapsOverview.map.removeLayer(layer);
}
@@ -1567,22 +1982,17 @@ document.querySelectorAll('#tab-unified-map input[type="checkbox"][data-layer]')
if (storageKey) localStorage.setItem(storageKey, String(enabled));
if (layerName === 'radar') {
if (!enabled && state.unifiedMapLayers.satellite) {
state.unifiedMapLayers.satellite = false;
const satToggle = document.getElementById('toggle-satellite');
if (satToggle) satToggle.checked = false;
}
setRadarLayerVisible(enabled);
if (!enabled && unifiedMap?.stopPlay) unifiedMap.stopPlay();
}
if (layerName === 'satellite') {
if (enabled) {
state.unifiedMapLayers.radar = true;
const radarToggle = document.getElementById('toggle-radar');
if (radarToggle) radarToggle.checked = true;
if (unifiedMapMode !== 'satellite') unifiedMapModeButton.click();
} else if (unifiedMapMode !== 'radar') {
unifiedMapModeButton.click();
setCloudLayerVisible(true);
} else {
setCloudLayerVisible(false);
if (unifiedMapMode !== 'radar') unifiedMapModeButton.click();
}
}
@@ -1614,6 +2024,17 @@ if (baseMapSelect) {
});
}
const layerMenuToggleBtn = document.getElementById('layer-menu-toggle');
const layerMenuPanel = document.getElementById('layer-menu-panel');
if (layerMenuToggleBtn && layerMenuPanel) {
layerMenuToggleBtn.addEventListener('click', () => {
const willShow = layerMenuPanel.classList.contains('hidden');
layerMenuPanel.classList.toggle('hidden', !willShow);
layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu';
layerMenuToggleBtn.setAttribute('aria-expanded', willShow ? 'true' : 'false');
});
}
if (dailyToggleBtn) {
dailyToggleBtn.addEventListener('click', () => {
state.showExtendedForecast = !state.showExtendedForecast;
+751 -9
View File
@@ -24,6 +24,7 @@
<button class="tab active" data-tab="conditions">Conditions</button>
<button class="tab" data-tab="unified-map">Map</button>
<button class="tab" data-tab="alerts">Alerts</button>
<button class="tab" data-tab="learn">Learn</button>
<button class="tab" data-tab="settings">Settings</button>
</nav>
<span class="tagline">Open-Meteo · RainViewer</span>
@@ -77,6 +78,8 @@
<div class="hero-center">
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
<div class="feels">Feels like <span id="cur-feels">--</span>°F</div>
<button id="share-hero-btn" class="hero-share-btn" type="button">Share hero card</button>
<p id="share-hero-status" class="hero-share-status hidden"></p>
</div>
<div class="hero-right">
<div class="hero-hilo">
@@ -243,44 +246,81 @@
</select>
</div>
<div class="map-layer-menu">
<button id="layer-menu-toggle" class="map-layer-menu-toggle" type="button" aria-expanded="false">Show Layer Menu</button>
<div id="layer-menu-panel" class="map-layer-menu-panel hidden">
<div class="map-layer-menu-title">Map Layers</div>
<div class="map-controls-toggle-grid">
<label class="toggle-control">
<input type="checkbox" id="toggle-radar" data-layer="radar" checked>
<span class="toggle-label">📡 Radar</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-radar" data-layer="radar" checked>
<span class="toggle-slider"></span>
</span>
</label>
<label class="toggle-control">
<span class="toggle-label">☁️ Clouds</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-satellite" data-layer="satellite">
<span class="toggle-label">🛰️ Satellite</span>
<span class="toggle-slider"></span>
</span>
</label>
<label class="toggle-control">
<input type="checkbox" id="toggle-lightning" data-layer="lightning">
<span class="toggle-label">⚡ Lightning</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-lightning" data-layer="lightning">
<span class="toggle-slider"></span>
</span>
</label>
<label class="toggle-control">
<input type="checkbox" id="toggle-wind" data-layer="wind" checked>
<span class="toggle-label">💨 Wind</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-wind" data-layer="wind" checked>
<span class="toggle-slider"></span>
</span>
</label>
<label class="toggle-control">
<span class="toggle-label">🌫 Air Quality</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-aqi" data-layer="aqi">
<span class="toggle-label">💨 Air Quality</span>
<span class="toggle-slider"></span>
</span>
</label>
<label class="toggle-control">
<input type="checkbox" id="toggle-alerts" data-layer="alerts">
<span class="toggle-label">⚠️ Alerts</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-alerts" data-layer="alerts">
<span class="toggle-slider"></span>
</span>
</label>
<label class="toggle-control">
<input type="checkbox" id="toggle-temp" data-layer="temp" checked>
<span class="toggle-label">🌡️ Temperature</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-temp" data-layer="temp" checked>
<span class="toggle-slider"></span>
</span>
</label>
<label class="toggle-control">
<input type="checkbox" id="toggle-sun-moon" data-layer="sun-moon">
<span class="toggle-label">☀️ Sun/Moon</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-sun-moon" data-layer="sun-moon">
<span class="toggle-slider"></span>
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Main Map -->
<div id="unified-map" class="unified-map-container"></div>
<div class="unified-map-shell">
<div id="unified-map" class="unified-map-container" style="height:56vh; min-height:420px;"></div>
<div class="map-overlay-readouts" id="map-overlay-readouts">
<span class="map-readout-chip" id="overlay-temp">Temp --</span>
<span class="map-readout-chip" id="overlay-aqi">AQI --</span>
<span class="map-readout-chip" id="overlay-sunmoon">Sun -- • Moon --</span>
</div>
</div>
<!-- Radar/Satellite Timeline Controls -->
<div class="map-timeline-wrap hidden" id="timeline-controls">
@@ -318,6 +358,10 @@
<span class="data-label">AQI</span>
<span id="panel-aqi">--</span>
</div>
<div class="data-item">
<span class="data-label">Cloud Cover</span>
<span id="panel-cloud">--</span>
</div>
</div>
</div>
</div>
@@ -328,8 +372,12 @@
<div id="alerts-wrap" class="alerts-wrap hidden">
<div class="section-head">
<span class="section-label">Weather Alerts</span>
<div class="alerts-actions">
<button id="share-weather-card-btn" class="link-btn" type="button">Share weather card</button>
<button id="notify-btn" class="link-btn" type="button">Enable notifications</button>
</div>
</div>
<p id="share-weather-status" class="alerts-share-status hidden"></p>
<div id="alerts-list" class="alerts-list"></div>
</div>
<p id="alerts-empty" class="panel-note">No active alerts for this location.</p>
@@ -366,6 +414,696 @@
</div>
</div>
<!-- ── TAB: LEARN ── -->
<div id="tab-learn" class="tab-panel hidden">
<div class="learn-wrap">
<h3 class="learn-title" id="learn-top">Weather Learning Guide</h3>
<p class="learn-intro">This page explains what each reading means and how to use it in daily decisions.</p>
<div class="learn-toc">
<h4 class="learn-toc-title">Contents</h4>
<div class="learn-toc-links">
<a href="#learn-aqi">AQI Bands</a>
<a href="#learn-wind">Wind Guide</a>
<a href="#learn-radar">Radar Legend</a>
<a href="#learn-map">Map Reading</a>
<a href="#learn-comfort">Humidity & Dew Point</a>
<a href="#learn-uv">UV Risk</a>
<a href="#learn-alerts">Alert Terms</a>
<a href="#learn-winter">Winter Types</a>
<a href="#learn-profiles">Risk Profiles</a>
<a href="#learn-matrix">Activity Matrix</a>
<a href="#learn-lightning">Lightning Safety</a>
<a href="#learn-flood">Flood Triggers</a>
<a href="#learn-confidence">Forecast Confidence</a>
<a href="#learn-faq">FAQ</a>
<a href="#learn-checklist">Emergency Checklist</a>
<a href="#learn-glossary">Glossary</a>
</div>
</div>
<div class="learn-grid">
<article class="learn-card">
<h4>Temperature</h4>
<p>Air temperature measured about 2 meters above ground. Use it for clothing and comfort.</p>
</article>
<article class="learn-card">
<h4>Feels Like</h4>
<p>Adjusted temperature that includes wind chill (cold) or heat index (hot/humid).</p>
</article>
<article class="learn-card">
<h4>Wind Speed & Direction</h4>
<p>Speed is how fast air moves. Direction is where wind comes from (for example, West means from the west).</p>
</article>
<article class="learn-card">
<h4>Wind Gusts</h4>
<p>Short bursts stronger than average wind. Important for driving, biking, and outdoor safety.</p>
</article>
<article class="learn-card">
<h4>Humidity</h4>
<p>How much moisture is in the air. Higher humidity can make warm weather feel hotter.</p>
</article>
<article class="learn-card">
<h4>Dew Point</h4>
<p>Temperature at which air becomes saturated. Higher dew point means a stickier, more humid feel.</p>
</article>
<article class="learn-card">
<h4>Pressure</h4>
<p>Atmospheric pressure in hPa. Falling pressure can signal unsettled weather.</p>
</article>
<article class="learn-card">
<h4>Visibility</h4>
<p>How far you can clearly see. Low visibility often occurs with fog, heavy rain, or snow.</p>
</article>
<article class="learn-card">
<h4>Cloud Cover</h4>
<p>Percent of sky covered by clouds. 0% is clear; 100% is overcast.</p>
</article>
<article class="learn-card">
<h4>Radar</h4>
<p>Shows precipitation intensity and motion. Use animation to see where rain/snow is moving.</p>
</article>
<article class="learn-card">
<h4>AQI (Air Quality Index)</h4>
<p>Higher AQI means worse air. Sensitive groups should reduce outdoor activity when AQI is elevated.</p>
</article>
<article class="learn-card">
<h4>Sunrise / Sunset / Moon</h4>
<p>Useful for planning daylight hours, photography, and evening conditions.</p>
</article>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-aqi">AQI Severity Bands (US AQI)</h4>
<p class="learn-section-intro">Higher values mean more pollution risk, especially for sensitive groups.</p>
<div class="learn-range-list">
<div class="learn-range-item">
<span class="range-swatch aqi-good"></span>
<span class="range-value">0-50</span>
<span class="range-label">Good</span>
<span class="range-note">Air is clean for most people.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch aqi-moderate"></span>
<span class="range-value">51-100</span>
<span class="range-label">Moderate</span>
<span class="range-note">Unusually sensitive people may notice symptoms.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch aqi-sensitive"></span>
<span class="range-value">101-150</span>
<span class="range-label">Unhealthy for Sensitive Groups</span>
<span class="range-note">Children, elderly, and respiratory patients should limit exertion.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch aqi-unhealthy"></span>
<span class="range-value">151-200</span>
<span class="range-label">Unhealthy</span>
<span class="range-note">Everyone may begin to feel effects; reduce prolonged outdoor activity.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch aqi-very-unhealthy"></span>
<span class="range-value">201-300</span>
<span class="range-label">Very Unhealthy</span>
<span class="range-note">Health alert conditions; stay indoors when possible.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch aqi-hazardous"></span>
<span class="range-value">301+</span>
<span class="range-label">Hazardous</span>
<span class="range-note">Emergency conditions; avoid outdoor exposure.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-wind">Wind Threshold Guide</h4>
<p class="learn-section-intro">Use sustained wind for general conditions and gusts for sudden impacts.</p>
<div class="learn-range-list">
<div class="learn-range-item">
<span class="range-swatch wind-calm"></span>
<span class="range-value">0-7 mph</span>
<span class="range-label">Calm to Light</span>
<span class="range-note">Little effect on activities.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch wind-breezy"></span>
<span class="range-value">8-18 mph</span>
<span class="range-label">Breezy</span>
<span class="range-note">Noticeable wind; light debris may move.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch wind-windy"></span>
<span class="range-value">19-31 mph</span>
<span class="range-label">Windy</span>
<span class="range-note">Harder for high-profile vehicles and cyclists.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch wind-strong"></span>
<span class="range-value">32-46 mph</span>
<span class="range-label">Strong</span>
<span class="range-note">Tree limbs can break; caution outdoors.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch wind-severe"></span>
<span class="range-value">47+ mph</span>
<span class="range-label">Severe / Damaging</span>
<span class="range-note">Potential structural impacts and power interruptions.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-radar">Radar Intensity Legend (General)</h4>
<p class="learn-section-intro">Color shades generally map to precipitation intensity from light to extreme.</p>
<div class="learn-range-list">
<div class="learn-range-item">
<span class="range-swatch radar-light"></span>
<span class="range-value">Light Blue / Green</span>
<span class="range-label">Light Rain or Snow</span>
<span class="range-note">Usually low impact, but can reduce visibility.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch radar-moderate"></span>
<span class="range-value">Yellow</span>
<span class="range-label">Moderate Precipitation</span>
<span class="range-note">Steadier rain/snow, pooling possible in poor drainage.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch radar-heavy"></span>
<span class="range-value">Orange / Red</span>
<span class="range-label">Heavy Rain / Strong Cells</span>
<span class="range-note">Rapid visibility drop and localized flooding risk.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch radar-extreme"></span>
<span class="range-value">Dark Red / Purple</span>
<span class="range-label">Very Intense Storm Core</span>
<span class="range-note">Severe weather possible; monitor warnings closely.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-map">How To Read The Map Quickly</h4>
<ul class="learn-list">
<li>Start with radar animation direction to see where precipitation is moving next 30-60 minutes.</li>
<li>Check wind arrows for transport direction of smoke, storms, and temperature changes.</li>
<li>Compare AQI and wind together: poor AQI plus low wind can mean slower air cleanup.</li>
<li>Use sunrise/sunset values to plan travel and outdoor time before dark.</li>
<li>Treat alerts as highest priority when present, even if current radar looks quiet.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-comfort">Humidity & Dew Point Comfort Scale</h4>
<p class="learn-section-intro">Dew point is often the best quick signal for how sticky the air will feel.</p>
<div class="learn-range-list">
<div class="learn-range-item">
<span class="range-swatch comfort-dry"></span>
<span class="range-value">Below 50 F</span>
<span class="range-label">Dry / Comfortable</span>
<span class="range-note">Most people feel comfortable, especially in shade.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch comfort-pleasant"></span>
<span class="range-value">50-59 F</span>
<span class="range-label">Pleasant</span>
<span class="range-note">Slight humidity, still comfortable for most activity.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch comfort-sticky"></span>
<span class="range-value">60-64 F</span>
<span class="range-label">Sticky</span>
<span class="range-note">Many people begin to notice muggy conditions.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch comfort-oppressive"></span>
<span class="range-value">65-69 F</span>
<span class="range-label">Very Humid</span>
<span class="range-note">Outdoor exertion can feel much harder.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch comfort-tropical"></span>
<span class="range-value">70 F+</span>
<span class="range-label">Oppressive / Tropical</span>
<span class="range-note">Heat stress risk rises quickly; hydrate and take breaks.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-pressure">Pressure Trend Clues</h4>
<p class="learn-section-intro">The trend matters more than a single pressure number.</p>
<ul class="learn-list">
<li>Rising pressure usually signals stabilizing weather and improving conditions.</li>
<li>Falling pressure often signals approaching storms, fronts, or unsettled weather.</li>
<li>A rapid pressure drop can be a warning sign for stronger systems.</li>
<li>Compare pressure changes with wind shifts and radar movement for better timing.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-visibility">Visibility Guide</h4>
<div class="learn-range-list">
<div class="learn-range-item">
<span class="range-swatch vis-good"></span>
<span class="range-value">6+ mi</span>
<span class="range-label">Good</span>
<span class="range-note">Normal driving and outdoor visibility.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch vis-fair"></span>
<span class="range-value">3-6 mi</span>
<span class="range-label">Fair</span>
<span class="range-note">Haze/light precipitation may reduce distant detail.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch vis-poor"></span>
<span class="range-value">1-3 mi</span>
<span class="range-label">Poor</span>
<span class="range-note">Use caution while driving; increase following distance.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch vis-danger"></span>
<span class="range-value">Below 1 mi</span>
<span class="range-label">Dangerous</span>
<span class="range-note">Fog/heavy precip can make travel hazardous.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-uv">UV Index Risk Bands</h4>
<div class="learn-range-list">
<div class="learn-range-item">
<span class="range-swatch uv-low"></span>
<span class="range-value">0-2</span>
<span class="range-label">Low</span>
<span class="range-note">Minimal protection needed for most people.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch uv-moderate"></span>
<span class="range-value">3-5</span>
<span class="range-label">Moderate</span>
<span class="range-note">Use sunscreen and sunglasses.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch uv-high"></span>
<span class="range-value">6-7</span>
<span class="range-label">High</span>
<span class="range-note">Limit midday sun and protect skin.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch uv-very-high"></span>
<span class="range-value">8-10</span>
<span class="range-label">Very High</span>
<span class="range-note">Burn risk rises quickly; seek shade often.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch uv-extreme"></span>
<span class="range-value">11+</span>
<span class="range-label">Extreme</span>
<span class="range-note">Unprotected skin can burn rapidly.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-alerts">Alert Terms: Watch vs Warning vs Advisory</h4>
<div class="learn-table">
<div class="learn-table-row learn-table-head">
<span>Term</span>
<span>Meaning</span>
<span>What To Do</span>
</div>
<div class="learn-table-row">
<span>Watch</span>
<span>Conditions are favorable.</span>
<span>Prepare and monitor updates.</span>
</div>
<div class="learn-table-row">
<span>Warning</span>
<span>Dangerous weather is happening or imminent.</span>
<span>Take action now to protect life and property.</span>
</div>
<div class="learn-table-row">
<span>Advisory</span>
<span>Hazardous conditions likely to cause inconvenience or minor impacts.</span>
<span>Use extra caution.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-precip">Precipitation Probability Basics</h4>
<ul class="learn-list">
<li>A 30% rain chance means a 3-in-10 chance of measurable precipitation at your location.</li>
<li>Probability does not equal duration: short heavy bursts can happen with moderate percentages.</li>
<li>Use radar animation with hourly probability for best near-term planning.</li>
<li>Snow type and accumulation depend strongly on near-surface temperature and layer depth aloft.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-cloud">Reading Cloud Cover</h4>
<div class="learn-range-list">
<div class="learn-range-item">
<span class="range-swatch cloud-clear"></span>
<span class="range-value">0-20%</span>
<span class="range-label">Mostly Clear</span>
<span class="range-note">Good sky visibility and strong daytime sun.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch cloud-partly"></span>
<span class="range-value">21-60%</span>
<span class="range-label">Partly Cloudy</span>
<span class="range-note">Variable sun with passing clouds.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch cloud-mostly"></span>
<span class="range-value">61-85%</span>
<span class="range-label">Mostly Cloudy</span>
<span class="range-note">Limited sunshine and cooler daytime heating.</span>
</div>
<div class="learn-range-item">
<span class="range-swatch cloud-overcast"></span>
<span class="range-value">86-100%</span>
<span class="range-label">Overcast</span>
<span class="range-note">Gray skies; often linked with steady precipitation systems.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-severe">Severe Weather Quick Signals</h4>
<ul class="learn-list">
<li>Rapidly strengthening radar cores plus rising winds can indicate stronger storm potential.</li>
<li>Sudden wind direction shifts near a storm can mark boundary passage or outflow.</li>
<li>Frequent lightning, intense rain rates, and repeated cells over one area raise flash-flood concern.</li>
<li>Always prioritize official warnings over visual interpretation.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-units">Unit Conversion Quick Reference</h4>
<div class="learn-table">
<div class="learn-table-row learn-table-head">
<span>Metric</span>
<span>US</span>
<span>Rule of Thumb</span>
</div>
<div class="learn-table-row">
<span>C</span>
<span>F</span>
<span>20C is about 68F, 30C is about 86F.</span>
</div>
<div class="learn-table-row">
<span>km/h</span>
<span>mph</span>
<span>mph is roughly km/h divided by 1.6.</span>
</div>
<div class="learn-table-row">
<span>mm</span>
<span>in</span>
<span>25 mm is about 1 inch.</span>
</div>
<div class="learn-table-row">
<span>km</span>
<span>mi</span>
<span>1 mile is about 1.6 km.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-data">Data Timing Notes</h4>
<ul class="learn-list">
<li>Current values and hourly forecasts can update at different cadences depending on source.</li>
<li>Radar frames can arrive every few minutes and may have occasional provider delays.</li>
<li>A single outlier value should be interpreted with recent trend context when possible.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-profiles">Personal Risk Profiles</h4>
<p class="learn-section-intro">These groups should use tighter limits when planning outdoor time.</p>
<div class="learn-table">
<div class="learn-table-row learn-table-head">
<span>Profile</span>
<span>Watch Closely</span>
<span>Suggested Action</span>
</div>
<div class="learn-table-row">
<span>Children</span>
<span>Heat index, AQI, lightning</span>
<span>Shorten play in heat/haze and move inside at thunder.</span>
</div>
<div class="learn-table-row">
<span>Older Adults</span>
<span>Heat, wind chill, poor AQI</span>
<span>Avoid peak heat/cold windows and reduce exertion.</span>
</div>
<div class="learn-table-row">
<span>Asthma/COPD</span>
<span>AQI, smoke transport wind</span>
<span>Limit outdoor effort when AQI rises above 100.</span>
</div>
<div class="learn-table-row">
<span>Outdoor Workers</span>
<span>Heat stress, lightning, strong gusts</span>
<span>Use hydration/rest cycles and stop work for nearby storms.</span>
</div>
<div class="learn-table-row">
<span>Pets</span>
<span>Hot pavement, cold wind, AQI</span>
<span>Shorten walks in extreme heat/cold and poor air.</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-matrix">Activity Decision Matrix</h4>
<p class="learn-section-intro">Use this quick matrix to decide if activity is generally good, caution, or postpone.</p>
<div class="learn-table">
<div class="learn-table-row learn-table-head">
<span>Activity</span>
<span>Caution Triggers</span>
<span>Postpone Triggers</span>
</div>
<div class="learn-table-row">
<span>Run / Bike</span>
<span>AQI 101+, gusts 25+ mph, heat index above 90 F</span>
<span>Lightning nearby, AQI 151+, visibility below 1 mile</span>
</div>
<div class="learn-table-row">
<span>Yard Work</span>
<span>Heat index 95+, sustained wind 25+ mph</span>
<span>Warning-level storms or severe gust risk</span>
</div>
<div class="learn-table-row">
<span>Commuting</span>
<span>Visibility 1-3 miles, heavy radar returns</span>
<span>Flooded roads, freezing rain, visibility below 1 mile</span>
</div>
<div class="learn-table-row">
<span>Grilling / Events</span>
<span>Gusts 20+ mph, rain cores approaching</span>
<span>Lightning or severe thunderstorm warnings</span>
</div>
<div class="learn-table-row">
<span>Long Drive</span>
<span>Strong crosswinds and moderate heavy precip</span>
<span>Ice accretion risk, flash-flood warnings, blizzard-like visibility</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-lightning">Lightning Safety Rules</h4>
<ul class="learn-list">
<li>30/30 rule: if thunder follows lightning within 30 seconds, move indoors immediately.</li>
<li>Wait at least 30 minutes after the last thunder before resuming outdoor activity.</li>
<li>Safe shelter means a fully enclosed building or hard-top vehicle with windows closed.</li>
<li>Avoid trees, open fields, metal fences, and water during thunderstorms.</li>
<li>If indoors, avoid corded electronics and plumbing during active lightning.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-flood">Flood and Heavy Rain Triggers</h4>
<ul class="learn-list">
<li>Slow-moving radar cores repeatedly passing over one area can cause flash flooding.</li>
<li>Urban areas flood faster due to hard surfaces and poor drainage.</li>
<li>Rising stream or ditch levels plus continued heavy rain indicate escalating risk.</li>
<li>Never drive through water-covered roads; depth and road damage are hard to judge.</li>
<li>Turn around, do not drown: the safest route is always to avoid flooded crossings.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-winter">Winter Precipitation Type Guide</h4>
<div class="learn-table">
<div class="learn-table-row learn-table-head">
<span>Type</span>
<span>Typical Setup</span>
<span>Common Impact</span>
</div>
<div class="learn-table-row">
<span>Snow</span>
<span>Colder layers top to bottom</span>
<span>Accumulation, lower visibility, slower travel</span>
</div>
<div class="learn-table-row">
<span>Sleet</span>
<span>Melting aloft then refreezing before ground</span>
<span>Pellet ice and slick roads</span>
</div>
<div class="learn-table-row">
<span>Freezing Rain</span>
<span>Warm layer aloft with shallow subfreezing surface</span>
<span>High-impact glaze ice on roads, trees, and power lines</span>
</div>
<div class="learn-table-row">
<span>Cold Rain</span>
<span>Surface above freezing</span>
<span>Wet roads without ice unless temps drop later</span>
</div>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-confidence">Forecast Confidence Explained</h4>
<div class="learn-mini-grid">
<article class="learn-mini-card">
<h5>High Confidence</h5>
<p>Models agree on timing and intensity. Day planning is usually stable.</p>
</article>
<article class="learn-mini-card">
<h5>Medium Confidence</h5>
<p>General pattern is clear, but timing or totals may shift.</p>
</article>
<article class="learn-mini-card">
<h5>Low Confidence</h5>
<p>Competing scenarios. Recheck often and plan with larger safety margins.</p>
</article>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-normals">Local Climate Normals Context</h4>
<ul class="learn-list">
<li>Compare today to normal seasonal values, not only yesterday.</li>
<li>A temperature departure of plus/minus 10 F from normal is usually notable.</li>
<li>Several wet or dry weeks in a row matter more than one unusual day.</li>
<li>Use normals to judge whether current patterns are typical for your area and month.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-faq">FAQ</h4>
<div class="learn-faq">
<details class="learn-faq-item">
<summary>Why does radar show rain but I do not feel it yet?</summary>
<p>Precipitation can evaporate before reaching the ground, or the core may pass just north/south of your exact location.</p>
</details>
<details class="learn-faq-item">
<summary>Why did temperature change quickly in one hour?</summary>
<p>A front passage, cloud break, rain-cooled outflow, or wind direction shift can change temperature rapidly.</p>
</details>
<details class="learn-faq-item">
<summary>Why is wind gust much higher than sustained wind?</summary>
<p>Sustained wind is an average over time, while gusts capture brief stronger bursts.</p>
</details>
<details class="learn-faq-item">
<summary>Why can AQI be poor even when sky looks clear?</summary>
<p>Fine particles and ozone are not always visible. Transport winds can carry pollution long distances.</p>
</details>
<details class="learn-faq-item">
<summary>Why does forecast rain timing keep shifting?</summary>
<p>Small speed changes in fronts or storm lines can move arrival by hours, especially in warm-season convection.</p>
</details>
<details class="learn-faq-item">
<summary>Why can one neighborhood get heavy rain while another stays dry?</summary>
<p>Thunderstorms are highly localized. A slight shift in storm track can create large differences over short distances.</p>
</details>
<details class="learn-faq-item">
<summary>Why does it feel hotter than the listed air temperature?</summary>
<p>High humidity reduces evaporative cooling from skin. Direct sun and low wind can make it feel even warmer.</p>
</details>
<details class="learn-faq-item">
<summary>Why is the app showing no precipitation while radar still has color?</summary>
<p>Radar reflectivity may detect virga, non-reaching snow grains, or weak echoes that do not produce measurable ground accumulation.</p>
</details>
<details class="learn-faq-item">
<summary>What is the difference between weather watch and warning again?</summary>
<p>A watch means conditions are favorable. A warning means hazardous weather is occurring or imminent and action should be taken now.</p>
</details>
<details class="learn-faq-item">
<summary>How often should I refresh during severe weather?</summary>
<p>Check every few minutes when storms are nearby. Radar and alerts can update quickly as conditions evolve.</p>
</details>
<details class="learn-faq-item">
<summary>Why does wind direction matter for storms and smoke?</summary>
<p>Wind direction controls where storm outflow, smoke, and pollution plumes travel, affecting local impact timing.</p>
</details>
<details class="learn-faq-item">
<summary>How can I tell if roads might flood soon?</summary>
<p>Look for repeated heavy radar cells over the same area, poor drainage zones, and active flood alerts.</p>
</details>
<details class="learn-faq-item">
<summary>Why does freezing rain cause more damage than snow?</summary>
<p>Freezing rain forms glaze ice on roads, trees, and power lines, creating severe traction and infrastructure risk.</p>
</details>
<details class="learn-faq-item">
<summary>Can good visibility still be dangerous for driving?</summary>
<p>Yes. Wind gusts, black ice, or sudden hydroplaning can create hazards even when visibility appears acceptable.</p>
</details>
<details class="learn-faq-item">
<summary>What should I trust most when map layers disagree?</summary>
<p>Prioritize official alerts first, then current observations/radar, then short-term forecast trends.</p>
</details>
<details class="learn-faq-item">
<summary>Why does sunrise or sunset time matter beyond daylight?</summary>
<p>Temperature, visibility, and road conditions can change quickly around sunrise/sunset, and glare risk increases.</p>
</details>
<details class="learn-faq-item">
<summary>How should I plan outdoor exercise using this app?</summary>
<p>Check AQI, temperature/feels-like, wind gusts, and radar trend together. Avoid peak heat, smoke, and nearby lightning.</p>
</details>
</div>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-checklist">Emergency Readiness Checklist</h4>
<ul class="learn-checklist">
<li>Water, food, medications, flashlight, and batteries for at least 72 hours.</li>
<li>Phone power bank charged before severe weather windows.</li>
<li>Family meeting point and communication plan.</li>
<li>Weather radio or alert-enabled phone notifications.</li>
<li>Vehicle fuel and alternate route plan for flood or ice risk.</li>
<li>Pet food, leash, and carrier ready for rapid relocation.</li>
</ul>
</div>
<div class="learn-section">
<h4 class="learn-section-title" id="learn-glossary">Glossary Quick Terms</h4>
<div class="learn-glossary-grid">
<article class="glossary-item"><h5>Dew Point</h5><p>Temperature where air saturates; stronger comfort indicator than humidity percent.</p></article>
<article class="glossary-item"><h5>Heat Index</h5><p>How hot it feels when humidity is combined with air temperature.</p></article>
<article class="glossary-item"><h5>Wind Chill</h5><p>How cold it feels when wind increases heat loss from skin.</p></article>
<article class="glossary-item"><h5>Reflectivity</h5><p>Radar measure of returned energy; higher values often indicate heavier precipitation.</p></article>
<article class="glossary-item"><h5>Advection</h5><p>Horizontal movement of air that transports heat, moisture, smoke, or haze.</p></article>
<article class="glossary-item"><h5>Inversion</h5><p>Layer where temperature rises with height, often trapping pollution near the surface.</p></article>
<article class="glossary-item"><h5>Outflow Boundary</h5><p>Cool air spreading from storms that can trigger new storm development.</p></article>
<article class="glossary-item"><h5>Nowcasting</h5><p>Very short-term forecasting focused on the next minutes to few hours.</p></article>
</div>
</div>
<a class="learn-back-top" href="#learn-top" aria-label="Back to top of Learn contents">Back to Top</a>
</div>
</div>
</div><!-- .app -->
<nav class="bottom-nav" aria-label="Mobile navigation">
@@ -381,6 +1119,10 @@
<span class="bottom-icon">⚠️</span>
<span class="bottom-label">Alerts</span>
</button>
<button class="bottom-tab" data-tab="learn" type="button">
<span class="bottom-icon">📘</span>
<span class="bottom-label">Learn</span>
</button>
<button class="bottom-tab" data-tab="settings" type="button">
<span class="bottom-icon">⚙️</span>
<span class="bottom-label">Settings</span>
+711 -50
View File
@@ -43,6 +43,7 @@ html, body {
font-family: var(--mono);
font-size: 16px;
line-height: 1.6;
scroll-behavior: smooth;
overflow-x: hidden;
}
@@ -64,17 +65,6 @@ a:hover { text-decoration: underline; }
gap: 0.8rem;
border: var(--surface-border);
border-radius: var(--surface-radius-lg);
/* ── Unified Map ── */
.unified-map-wrap {
border: var(--surface-border);
border-radius: var(--surface-radius-md);
background: var(--card-bg);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
background: linear-gradient(180deg, var(--card-bg-soft), var(--paper-dark));
color: var(--card-ink);
padding: 1rem 1.1rem;
@@ -93,8 +83,8 @@ a:hover { text-decoration: underline; }
}
.tagline {
font-size: 0.74rem;
letter-spacing: 0.12em;
font-size: 0.88rem;
letter-spacing: 0.08em;
color: var(--card-sub);
text-transform: uppercase;
white-space: nowrap;
@@ -104,6 +94,22 @@ a:hover { text-decoration: underline; }
.tabs {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
/* ── Unified Map ── */
.unified-map-wrap {
border: var(--surface-border);
border-radius: var(--surface-radius-md);
background: var(--card-bg);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Map Controls ── */
.map-controls-header {
display: flex;
@@ -131,14 +137,58 @@ a:hover { text-decoration: underline; }
border-radius: var(--surface-radius-sm);
background: var(--card-bg-soft);
color: var(--card-ink);
font-size: 0.85rem;
font-size: 1rem;
cursor: pointer;
}
.map-controls-toggle-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr;
gap: 0.5rem;
margin-top: 0.55rem;
}
.map-layer-menu {
border: var(--surface-border);
border-radius: var(--surface-radius-sm);
background: var(--card-bg-soft);
padding: 0.45rem 0.5rem 0.55rem;
}
.map-layer-menu-toggle {
width: 100%;
text-align: left;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.45rem 0.6rem;
cursor: pointer;
user-select: none;
font-family: var(--mono);
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--card-ink);
transition: background 0.15s;
}
.map-layer-menu-toggle:hover {
background: var(--paper-dark);
}
.map-layer-menu-title {
margin-top: 0.55rem;
font-family: var(--mono);
font-size: 0.88rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--card-sub);
}
.map-layer-menu-panel {
margin-top: 0.45rem;
}
.toggle-control {
@@ -149,6 +199,7 @@ a:hover { text-decoration: underline; }
cursor: pointer;
border-radius: var(--surface-radius-sm);
transition: background 0.15s;
font-family: var(--mono);
}
.toggle-control:hover {
@@ -160,8 +211,10 @@ a:hover { text-decoration: underline; }
}
.toggle-label {
font-size: 0.85rem;
color: var(--card-sub);
font-size: 1rem;
color: var(--card-ink);
font-weight: 700;
letter-spacing: 0.015em;
}
.unified-map-container {
@@ -172,6 +225,38 @@ a:hover { text-decoration: underline; }
overflow: hidden;
}
.unified-map-shell {
position: relative;
}
.map-overlay-readouts {
position: absolute;
left: 0.7rem;
right: 0.7rem;
bottom: 0.7rem;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
z-index: 650;
pointer-events: none;
}
.map-readout-chip {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.62rem;
border-radius: 999px;
background: rgba(20, 18, 14, 0.9);
border: 1px solid rgba(255, 255, 255, 0.24);
color: #fff;
font-family: var(--mono);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.01em;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.34);
}
.map-timeline-wrap {
padding: 0.6rem 0.75rem;
background: var(--paper-darker);
@@ -183,22 +268,22 @@ a:hover { text-decoration: underline; }
background: var(--card-bg-soft);
border: var(--surface-border);
border-radius: var(--surface-radius-sm);
padding: 1rem;
padding: 1rem 1.1rem;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr;
gap: 1rem;
}
.panel-location {
font-weight: 600;
font-weight: 700;
color: var(--card-ink);
font-size: 0.95rem;
font-size: 1.08rem;
}
.panel-data {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
grid-template-columns: repeat(3, 1fr);
gap: 0.85rem;
}
.data-item {
@@ -207,25 +292,47 @@ a:hover { text-decoration: underline; }
gap: 0.25rem;
}
.data-label {
font-size: 0.75rem;
color: var(--card-sub);
text-transform: uppercase;
font-weight: 500;
.leaflet-tooltip.map-label {
background: rgba(20, 18, 14, 0.9);
border: 1px solid rgba(255, 255, 255, 0.22);
color: #fff;
border-radius: 999px;
padding: 0.18rem 0.52rem;
font-family: var(--mono);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.01em;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.55);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
.leaflet-tooltip.map-label:before {
border-top-color: rgba(20, 18, 14, 0.9);
}
.data-label {
font-size: 0.78rem;
color: var(--card-sub);
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.06em;
}
.data-item span:last-child {
font-size: 1.02rem;
color: var(--card-ink);
font-weight: 700;
}
.tab {
background: transparent;
border: none;
border-right: 1px solid var(--border);
padding: 0.4rem 1rem;
font-family: var(--mono);
font-size: 0.84rem;
font-size: 0.95rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-mid);
@@ -274,7 +381,7 @@ a:hover { text-decoration: underline; }
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 0.6rem 1.1rem;
font-family: var(--mono);
font-size: 0.84rem;
font-size: 0.95rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
@@ -343,7 +450,7 @@ a:hover { text-decoration: underline; }
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid var(--surface-border);
border: var(--surface-border);
border-radius: 14px;
transition: all 0.3s ease;
display: flex;
@@ -512,6 +619,366 @@ a:hover { text-decoration: underline; }
/* ── Tab panels ── */
.tab-panel { width: 100%; }
/* ── Learn tab ── */
.learn-wrap {
border: var(--surface-border);
border-radius: var(--surface-radius-md);
background: var(--card-bg);
padding: 1rem;
}
.learn-title {
font-family: var(--serif);
font-size: 1.35rem;
color: var(--card-ink);
margin-bottom: 0.35rem;
}
.learn-intro {
color: var(--card-sub);
margin-bottom: 0.95rem;
}
.learn-toc {
border: var(--surface-border);
border-radius: 10px;
background: var(--card-bg-soft);
padding: 0.6rem;
margin-bottom: 0.9rem;
}
.learn-toc-title {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--card-sub);
margin-bottom: 0.4rem;
}
.learn-toc-links {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.32rem;
}
.learn-toc-links a {
border: var(--surface-border);
border-radius: 999px;
background: rgba(0, 0, 0, 0.16);
color: var(--card-ink);
text-decoration: none;
font-size: 0.72rem;
font-weight: 700;
padding: 0.24rem 0.42rem;
text-align: center;
line-height: 1.2;
}
.learn-toc-links a:hover {
background: rgba(0, 0, 0, 0.3);
}
.learn-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.learn-card {
border: var(--surface-border);
border-radius: var(--surface-radius-sm);
background: var(--card-bg-soft);
padding: 0.75rem;
}
.learn-card h4 {
font-size: 0.93rem;
color: var(--card-ink);
margin-bottom: 0.3rem;
}
.learn-card p {
font-size: 0.84rem;
color: var(--card-sub);
line-height: 1.45;
}
.learn-section {
margin-top: 1rem;
border-top: var(--surface-border);
padding-top: 0.9rem;
}
.learn-section-title {
font-size: 1rem;
color: var(--card-ink);
margin-bottom: 0.25rem;
}
.learn-section-intro {
font-size: 0.82rem;
color: var(--card-sub);
margin-bottom: 0.6rem;
}
.learn-range-list {
display: grid;
gap: 0.45rem;
}
.learn-range-item {
display: grid;
grid-template-columns: 18px 108px minmax(180px, 1fr) 1.5fr;
align-items: center;
gap: 0.55rem;
border: var(--surface-border);
border-radius: 10px;
background: var(--card-bg-soft);
padding: 0.5rem 0.6rem;
}
.range-swatch {
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.4);
}
.range-value {
font-size: 0.79rem;
color: var(--card-ink);
font-weight: 700;
}
.range-label {
font-size: 0.8rem;
color: var(--card-ink);
font-weight: 600;
}
.range-note {
font-size: 0.77rem;
color: var(--card-sub);
}
.learn-table {
display: grid;
border: var(--surface-border);
border-radius: 10px;
overflow: hidden;
}
.learn-table-row {
display: grid;
grid-template-columns: 120px 1.2fr 1.6fr;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
background: var(--card-bg-soft);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.learn-table-row:last-child {
border-bottom: none;
}
.learn-table-row span {
font-size: 0.78rem;
color: var(--card-sub);
}
.learn-table-head {
background: rgba(0, 0, 0, 0.18);
}
.learn-table-head span {
color: var(--card-ink);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.learn-table-row span:first-child {
color: var(--card-ink);
font-weight: 700;
}
.aqi-good { background: #00a650; }
.aqi-moderate { background: #ffd000; }
.aqi-sensitive { background: #ff8c00; }
.aqi-unhealthy { background: #e53935; }
.aqi-very-unhealthy { background: #8e24aa; }
.aqi-hazardous { background: #7e0023; }
.wind-calm { background: #4fc3f7; }
.wind-breezy { background: #43a047; }
.wind-windy { background: #fdd835; }
.wind-strong { background: #fb8c00; }
.wind-severe { background: #e53935; }
.radar-light { background: #1e88e5; }
.radar-moderate { background: #fdd835; }
.radar-heavy { background: #f4511e; }
.radar-extreme { background: #8e24aa; }
.comfort-dry { background: #4fc3f7; }
.comfort-pleasant { background: #66bb6a; }
.comfort-sticky { background: #fdd835; }
.comfort-oppressive { background: #fb8c00; }
.comfort-tropical { background: #e53935; }
.vis-good { background: #43a047; }
.vis-fair { background: #fdd835; }
.vis-poor { background: #fb8c00; }
.vis-danger { background: #e53935; }
.uv-low { background: #43a047; }
.uv-moderate { background: #fdd835; }
.uv-high { background: #fb8c00; }
.uv-very-high { background: #e53935; }
.uv-extreme { background: #8e24aa; }
.cloud-clear { background: #64b5f6; }
.cloud-partly { background: #90caf9; }
.cloud-mostly { background: #78909c; }
.cloud-overcast { background: #546e7a; }
.learn-list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.35rem;
color: var(--card-sub);
font-size: 0.84rem;
line-height: 1.45;
}
.learn-mini-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
.learn-mini-card {
border: var(--surface-border);
border-radius: 10px;
background: var(--card-bg-soft);
padding: 0.55rem 0.6rem;
}
.learn-mini-card h5 {
margin: 0 0 0.25rem;
font-size: 0.8rem;
color: var(--card-ink);
}
.learn-mini-card p {
margin: 0;
font-size: 0.78rem;
color: var(--card-sub);
line-height: 1.4;
}
.learn-faq {
display: grid;
gap: 0.45rem;
}
.learn-faq-item {
border: var(--surface-border);
border-radius: 10px;
background: var(--card-bg-soft);
padding: 0.5rem 0.6rem;
}
.learn-faq-item summary {
cursor: pointer;
font-size: 0.82rem;
font-weight: 700;
color: var(--card-ink);
}
.learn-faq-item p {
margin: 0.45rem 0 0;
font-size: 0.79rem;
color: var(--card-sub);
line-height: 1.45;
}
.learn-checklist {
margin: 0;
padding-left: 0;
list-style: none;
display: grid;
gap: 0.4rem;
}
.learn-checklist li {
border: var(--surface-border);
border-radius: 10px;
background: var(--card-bg-soft);
padding: 0.45rem 0.6rem 0.45rem 1.95rem;
position: relative;
font-size: 0.8rem;
color: var(--card-sub);
line-height: 1.4;
}
.learn-checklist li::before {
content: '✓';
position: absolute;
left: 0.65rem;
top: 0.45rem;
color: var(--accent);
font-weight: 800;
}
.learn-glossary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.glossary-item {
border: var(--surface-border);
border-radius: 10px;
background: var(--card-bg-soft);
padding: 0.55rem 0.6rem;
}
.glossary-item h5 {
margin: 0 0 0.2rem;
font-size: 0.8rem;
color: var(--card-ink);
}
.glossary-item p {
margin: 0;
font-size: 0.77rem;
color: var(--card-sub);
line-height: 1.4;
}
.learn-back-top {
position: fixed;
right: 1rem;
bottom: calc(0.9rem + env(safe-area-inset-bottom, 0px));
z-index: 460;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 999px;
background: rgba(20, 18, 14, 0.94);
color: #fff;
text-decoration: none;
font-size: 0.78rem;
font-weight: 700;
padding: 0.42rem 0.72rem;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
.learn-back-top:hover {
background: rgba(20, 18, 14, 1);
}
/* ── Location header ── */
.loc-header {
display: flex;
@@ -557,6 +1024,31 @@ a:hover { text-decoration: underline; }
.hero-center { text-align: center; }
.hero-share-btn {
margin-top: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
background: rgba(0, 0, 0, 0.2);
color: var(--card-ink);
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.3rem 0.62rem;
cursor: pointer;
}
.hero-share-btn:hover {
background: rgba(0, 0, 0, 0.34);
}
.hero-share-status {
margin-top: 0.25rem;
min-height: 1rem;
font-size: 0.7rem;
color: var(--card-sub);
}
.hero-temp {
font-family: var(--serif);
font-size: 6.5rem;
@@ -652,7 +1144,7 @@ a:hover { text-decoration: underline; }
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
font-size: 1rem;
font-weight: 500;
color: var(--card-sub);
border-top: 1px solid rgba(255, 255, 255, 0.12);
@@ -684,7 +1176,7 @@ a:hover { text-decoration: underline; }
.stat-card .stat-label {
font-size: 0.68rem;
letter-spacing: 0.12em;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
color: var(--card-sub);
@@ -697,7 +1189,7 @@ a:hover { text-decoration: underline; }
color: var(--card-ink);
}
.stat-unit { font-size: 0.85rem; font-weight: 600; color: var(--card-sub); margin-left: 2px; }
.stat-unit { font-size: 1rem; font-weight: 600; color: var(--card-sub); margin-left: 2px; }
.stat-sub { font-size: 0.8rem; font-weight: 500; color: var(--card-sub); }
/* ── Details Drawer ── */
@@ -712,7 +1204,7 @@ a:hover { text-decoration: underline; }
.drawer-summary {
list-style: none;
font-size: 0.68rem;
letter-spacing: 0.12em;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--card-sub);
padding: 0.75rem 1rem;
@@ -768,19 +1260,120 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
background: var(--card-bg);
}
.alerts-actions {
display: inline-flex;
gap: 0.45rem;
align-items: center;
}
.alerts-share-status {
margin: 0.3rem 0 0.5rem;
font-size: 0.74rem;
color: var(--card-sub);
}
.alerts-list {
display: grid;
gap: 0.5rem;
}
.alert-item {
display: grid;
gap: 0.2rem;
.alerts-group {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.55rem;
padding: 0.45rem;
background: rgba(0, 0, 0, 0.08);
}
.alerts-group-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
padding: 0.1rem 0.2rem;
}
.alerts-group-title {
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--card-ink);
}
.alerts-group-hint {
font-size: 0.7rem;
color: var(--card-sub);
}
.alerts-group-extreme .alerts-group-title,
.alerts-group-severe .alerts-group-title {
color: #f28b82;
}
.alerts-group-moderate .alerts-group-title {
color: #ffd166;
}
.alert-item {
display: grid;
gap: 0.3rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.58rem;
background: var(--card-bg-soft);
font-size: 0.72rem;
font-size: 0.74rem;
}
.alert-top {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
}
.alert-severity {
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 0.06rem 0.45rem;
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 700;
}
.severity-extreme,
.severity-severe {
background: rgba(229, 57, 53, 0.26);
color: #ffd3d1;
}
.severity-moderate {
background: rgba(255, 209, 102, 0.25);
color: #ffe4a3;
}
.severity-minor,
.severity-unknown {
background: rgba(120, 144, 156, 0.25);
color: #e3eef5;
}
.alert-timing {
font-size: 0.7rem;
color: var(--card-sub);
}
.alert-message {
font-size: 0.74rem;
color: var(--card-sub);
line-height: 1.35;
}
.alert-action {
border-top: 1px dashed rgba(255, 255, 255, 0.14);
padding-top: 0.3rem;
font-size: 0.73rem;
color: var(--card-ink);
font-weight: 600;
}
.panel-note {
@@ -868,11 +1461,11 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
.overlay-toggle {
padding: 0.5rem 1rem;
border: 1px solid var(--surface-border);
border: var(--surface-border);
border-radius: var(--surface-radius-sm);
background: var(--card-bg-soft);
color: var(--card-sub);
font-size: 0.85rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
@@ -998,7 +1591,7 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
margin-bottom: 0.6rem;
}
.section-label { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.14em; color: var(--ink); text-transform: uppercase; }
.section-label { font-size: 0.88rem; font-weight: 800; letter-spacing: 0.14em; color: var(--ink); text-transform: uppercase; }
.section-note { font-size: 0.72rem; color: var(--card-sub); font-style: italic; }
.link-btn {
@@ -1092,7 +1685,7 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
.tl-tick.forecast { opacity: 0.55; }
.radar-ts {
font-size: 0.74rem;
font-size: 0.88rem;
color: var(--ink);
font-weight: 600;
letter-spacing: 0.04em;
@@ -1342,7 +1935,7 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
gap: 0;
}
.search-row input {
font-size: 0.84rem;
font-size: 0.95rem;
padding: 0.58rem 0.68rem;
}
.search-row button {
@@ -1370,6 +1963,7 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
.hero-temp { font-size: 5.4rem; }
.hero-desc { font-size: 1.55rem; }
.hero-sub { font-size: 0.92rem; }
.hero-share-btn { font-size: 0.7rem; }
.feels { font-size: 0.95rem; }
.hilo-item { font-size: 1.35rem; }
.hilo-label { font-size: 0.78rem; }
@@ -1395,7 +1989,7 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
/* Hourly readability */
.hour-cell { min-width: 88px; padding: 0.8rem 0.55rem; }
.hour-time { font-size: 0.74rem; }
.hour-time { font-size: 0.88rem; }
.hour-icon { font-size: 1.6rem; }
.hour-temp { font-size: 1.08rem; }
.hour-precip { font-size: 0.78rem; }
@@ -1414,7 +2008,7 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
.hero-hilo { flex-direction: row; gap: 1.5rem; padding: 0.75rem 1.2rem; }
.hilo-divider { width: 1px; height: 2rem; }
.card-group { grid-template-columns: repeat(2, 1fr); }
.card-group { grid-template-columns: 1fr; }
.recs-grid { grid-template-columns: repeat(3, 1fr); }
.day-row { grid-template-columns: 80px 36px 1fr 70px; }
.day-meta { display: none; }
@@ -1426,4 +2020,71 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
.maps-data { grid-template-columns: 1fr; }
.maps-legend { grid-template-columns: 1fr; }
.wind-stats { grid-template-columns: 1fr 1fr; }
.alerts-actions {
display: grid;
gap: 0.15rem;
justify-items: end;
}
.alerts-group-head {
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
}
.learn-grid { grid-template-columns: 1fr; }
.learn-toc-links {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.learn-range-item {
grid-template-columns: 16px 1fr;
align-items: start;
}
.range-value,
.range-label,
.range-note {
grid-column: 2;
}
.learn-table-row {
grid-template-columns: 1fr;
gap: 0.2rem;
}
.learn-table-head {
display: none;
}
.learn-table-row span {
font-size: 0.8rem;
}
.learn-mini-grid,
.learn-glossary-grid {
grid-template-columns: 1fr;
}
.learn-back-top {
right: 0.65rem;
bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
font-size: 0.74rem;
padding: 0.36rem 0.6rem;
}
.map-overlay-readouts {
left: 0.45rem;
right: 0.45rem;
bottom: 0.45rem;
gap: 0.35rem;
}
.map-readout-chip {
font-size: 0.72rem;
padding: 0.26rem 0.52rem;
}
}
+37 -12
View File
@@ -277,23 +277,48 @@ app.get('/api/alerts', async (req, res) => {
}
});
// RainViewer timestamps proxy
// Radar/satellite tile configuration
app.get('/api/radar/times', async (req, res) => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000);
const now = Math.floor(Date.now() / 1000);
const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', {
signal: controller.signal,
// IEM NEXRAD tile service (US radar, public/free) with historical offsets for animation.
const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0';
const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0];
const radarPast = radarOffsetsMin.map(offset => {
const layer = offset === 0 ? 'nexrad-n0q-900913' : `nexrad-n0q-900913-m${String(offset).padStart(2, '0')}m`;
return {
time: now - (offset * 60),
url: `${radarLayerBase}/${layer}/{z}/{x}/{y}.png`,
isForecast: false,
maxNativeZoom: 12,
};
});
// IEM GOES East cloud/satellite frames for animation.
const satOffsetsMin = [30, 25, 20, 15, 10, 5, 0];
const satelliteInfrared = satOffsetsMin.map(offset => {
const layer = offset === 0 ? 'goes_east' : `goes_east_m${String(offset).padStart(2, '0')}m`;
return {
time: now - (offset * 60),
url: `https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/${layer}/{z}/{x}/{y}.png`,
isForecast: false,
maxNativeZoom: 10,
};
});
res.json({
provider: 'iem-radar-rainviewer-sat',
radar: {
past: radarPast,
nowcast: [],
},
satellite: {
infrared: satelliteInfrared,
},
});
clearTimeout(timeoutId);
if (!r.ok) {
throw new Error(`RainViewer API returned ${r.status}`);
}
const data = await r.json();
res.json(data);
} catch (e) {
console.error('Radar times fetch error:', e.message);
console.error('Radar config error:', e.message);
res.status(500).json({ error: 'Radar times failed', detail: e.message });
}
});