Stabilize SW and cache headers to match backup behavior
This commit is contained in:
1 parent
30045c696e
commit
14995987ae
3 files changed
+627
-104
No files matched your search
+434
-85
@@ -48,6 +48,7 @@ const state = {
|
||||
lastRefreshAt: 0,
|
||||
lightningMarkers: [],
|
||||
windArrows: [],
|
||||
showExtendedForecast: false,
|
||||
};
|
||||
|
||||
// ── DOM ──────────────────────────────────────────────────────────────────────
|
||||
@@ -71,8 +72,8 @@ const mapsLocationEl = document.getElementById('maps-location');
|
||||
const mapsWindEl = document.getElementById('maps-wind');
|
||||
const lightningToggle = document.getElementById('lightning-toggle');
|
||||
const windToggle = document.getElementById('wind-toggle');
|
||||
const lightningStatus = document.getElementById('lightning-status');
|
||||
const windStatus = document.getElementById('wind-status');
|
||||
const dailyToggleBtn = document.getElementById('daily-toggle-btn');
|
||||
const dailyForecastLabel = document.getElementById('daily-forecast-label');
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const show = el => el.classList.remove('hidden');
|
||||
@@ -84,6 +85,10 @@ function compassDir(deg) {
|
||||
return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8];
|
||||
}
|
||||
|
||||
function compassDirFull(deg) {
|
||||
return ['North','Northeast','East','Southeast','South','Southwest','West','Northwest'][Math.round(deg / 45) % 8];
|
||||
}
|
||||
|
||||
function formatHour(iso) {
|
||||
const h = new Date(iso).getHours();
|
||||
return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`;
|
||||
@@ -181,6 +186,58 @@ function precipType(code, tempF, rain, snowfall) {
|
||||
return 'Dry';
|
||||
}
|
||||
|
||||
function calcDewpoint(tempF, rh) {
|
||||
const tc = (tempF - 32) * 5 / 9;
|
||||
const alpha = Math.log(rh / 100) + (17.62 * tc) / (243.12 + tc);
|
||||
const dewC = (243.12 * alpha) / (17.62 - alpha);
|
||||
return state.units === 'us' ? Math.round(dewC * 9 / 5 + 32) : Math.round(dewC);
|
||||
}
|
||||
|
||||
function humidityFeel(rh) {
|
||||
if (rh < 25) return 'Very dry';
|
||||
if (rh < 35) return 'Dry';
|
||||
if (rh < 55) return 'Comfortable';
|
||||
if (rh < 65) return 'Moderate';
|
||||
if (rh < 75) return 'Humid';
|
||||
return 'Very humid';
|
||||
}
|
||||
|
||||
function renderRecommendations(data) {
|
||||
const c = data.current;
|
||||
const d = data.daily;
|
||||
const temp = c.temperature_2m;
|
||||
const code = c.weather_code;
|
||||
const uv = d.uv_index_max[0] ?? 0;
|
||||
const wind = c.wind_speed_10m;
|
||||
const items = [];
|
||||
|
||||
if (temp <= 32) items.push({ icon: '🧥', label: 'Heavy coat' });
|
||||
else if (temp <= 50) items.push({ icon: '🧣', label: 'Jacket' });
|
||||
else if (temp >= 85) items.push({ icon: '👕', label: 'Light clothes' });
|
||||
|
||||
if ([51,53,55,61,63,65,66,67,80,81,82].includes(code)) {
|
||||
items.push({ icon: '☂️', label: 'Umbrella' });
|
||||
items.push({ icon: '🥾', label: 'Waterproof\nshoes' });
|
||||
}
|
||||
if ([71,73,75,77,85,86].includes(code)) {
|
||||
items.push({ icon: '🥾', label: 'Snow boots' });
|
||||
items.push({ icon: '🧤', label: 'Gloves' });
|
||||
}
|
||||
if (code >= 95) items.push({ icon: '⚡', label: 'Stay\nindoors' });
|
||||
if (c.is_day && uv >= 6) {
|
||||
items.push({ icon: '🕶️', label: 'Sunglasses' });
|
||||
items.push({ icon: '🧴', label: 'Sunscreen' });
|
||||
}
|
||||
if (wind >= 20) items.push({ icon: '💨', label: 'Windbreaker' });
|
||||
items.push({ icon: '💧', label: 'Water bottle' });
|
||||
|
||||
const grid = document.getElementById('recommendations');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = items.map(it =>
|
||||
`<div class="rec-item"><span class="rec-icon">${it.icon}</span><span class="rec-label">${it.label}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function apparentLabel(tempF, apparentF) {
|
||||
if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill';
|
||||
if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index';
|
||||
@@ -258,29 +315,38 @@ function renderSavedCities() {
|
||||
}
|
||||
|
||||
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
function activateTab(tabName) {
|
||||
document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => hide(p));
|
||||
btn.classList.add('active');
|
||||
show(document.getElementById('tab-' + btn.dataset.tab));
|
||||
if (btn.dataset.tab === 'radar-full') {
|
||||
setTimeout(() => { radarFull.map && radarFull.map.invalidateSize(); }, 50);
|
||||
document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active'));
|
||||
show(document.getElementById('tab-' + tabName));
|
||||
|
||||
if (tabName === 'conditions') {
|
||||
setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 60);
|
||||
}
|
||||
if (btn.dataset.tab === 'maps') {
|
||||
if (tabName === 'radar-full') {
|
||||
setTimeout(() => { radarFull.refresh && radarFull.refresh(); }, 50);
|
||||
}
|
||||
if (tabName === 'maps') {
|
||||
setTimeout(refreshMapsOverview, 80);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab, .bottom-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
document.getElementById('open-radar-tab').addEventListener('click', () => {
|
||||
document.querySelector('.tab[data-tab="radar-full"]').click();
|
||||
activateTab('radar-full');
|
||||
});
|
||||
|
||||
// ── APIs ─────────────────────────────────────────────────────────────────────
|
||||
async function geocode(q) {
|
||||
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
|
||||
if (!r.ok) throw new Error('Geocoding failed');
|
||||
if (!r.ok) {
|
||||
const detail = await r.text().catch(() => '');
|
||||
throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
@@ -290,15 +356,40 @@ async function reverseGeocode(lat, lon) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function fetchJsonWithTimeout(url, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const response = await fetch(url, { cache: 'no-store', signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Request failed (${response.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchWeather(lat, lon, tz) {
|
||||
const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=us`);
|
||||
if (!r.ok) throw new Error('Weather fetch failed');
|
||||
return r.json();
|
||||
const proxyUrl = `/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`;
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
return await fetchJsonWithTimeout(proxyUrl, 22000);
|
||||
} catch (err) {
|
||||
const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || err);
|
||||
if (attempt >= 3) {
|
||||
throw new Error(`Weather proxy failed after ${attempt} attempts: ${reason}`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAirQuality(lat, lon, tz) {
|
||||
const r = await fetch(`/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`);
|
||||
if (!r.ok) throw new Error('Air quality fetch failed');
|
||||
const url = `/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`;
|
||||
console.log('Fetching air quality from:', url);
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`Air quality fetch failed with status ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
@@ -338,6 +429,9 @@ async function buildDetectedCity(lat, lon) {
|
||||
|
||||
// ── Radar engine ──────────────────────────────────────────────────────────────
|
||||
function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
const FRAME_INTERVAL_MS = 650;
|
||||
const FRAME_FADE_MS = 240;
|
||||
|
||||
const map = L.map(mapEl, {
|
||||
center: [39.5, -98.35],
|
||||
zoom: 4,
|
||||
@@ -357,6 +451,9 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
let playing = false;
|
||||
let animTimer = null;
|
||||
let locationMarker = null;
|
||||
let visibleLayer = null;
|
||||
let framesReady = false;
|
||||
let loadingFrames = false;
|
||||
|
||||
function activeFrames() {
|
||||
return mode === 'radar' ? radarFrames : satelliteFrames;
|
||||
@@ -382,12 +479,33 @@ 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.75, maxZoom: 15 });
|
||||
layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0, maxZoom: 15 });
|
||||
}
|
||||
|
||||
Object.values(layers.radar).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
|
||||
Object.values(layers.satellite).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
|
||||
layers[mode][frame.time].addTo(map);
|
||||
Object.values(layers.radar).forEach(l => {
|
||||
if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l);
|
||||
});
|
||||
Object.values(layers.satellite).forEach(l => {
|
||||
if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l);
|
||||
});
|
||||
|
||||
const nextLayer = layers[mode][frame.time];
|
||||
if (!map.hasLayer(nextLayer)) {
|
||||
nextLayer.setOpacity(0);
|
||||
nextLayer.addTo(map);
|
||||
}
|
||||
|
||||
nextLayer.setOpacity(0.78);
|
||||
|
||||
if (visibleLayer && visibleLayer !== nextLayer && map.hasLayer(visibleLayer)) {
|
||||
visibleLayer.setOpacity(0.2);
|
||||
const prevLayer = visibleLayer;
|
||||
setTimeout(() => {
|
||||
if (prevLayer !== visibleLayer && map.hasLayer(prevLayer)) map.removeLayer(prevLayer);
|
||||
}, FRAME_FADE_MS);
|
||||
}
|
||||
|
||||
visibleLayer = nextLayer;
|
||||
|
||||
tsEl.textContent = tsLabel(frame.time);
|
||||
document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => {
|
||||
@@ -420,7 +538,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
function startPlay() {
|
||||
playing = true;
|
||||
playBtn.textContent = '⏸';
|
||||
animTimer = setInterval(step, 500);
|
||||
animTimer = setInterval(step, FRAME_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopPlay() {
|
||||
@@ -436,6 +554,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
if (!(radarFrames.length && satelliteFrames.length)) return;
|
||||
mode = mode === 'radar' ? 'satellite' : 'radar';
|
||||
layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
|
||||
visibleLayer = null;
|
||||
buildTimeline();
|
||||
const idx = Math.min(currentIdx, activeFrames().length - 1);
|
||||
showFrame(Math.max(idx, 0));
|
||||
@@ -443,8 +562,23 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
}
|
||||
|
||||
async function loadFrames() {
|
||||
if (loadingFrames || framesReady) return;
|
||||
loadingFrames = true;
|
||||
let retries = 0;
|
||||
const maxRetries = 2;
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/radar/times');
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
||||
|
||||
const r = await fetch('/api/radar/times', { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error(`Radar fetch returned ${r.status}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
|
||||
radarFrames = [];
|
||||
@@ -454,7 +588,10 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
(data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false }));
|
||||
|
||||
if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
|
||||
if (!radarFrames.length && !satelliteFrames.length) return;
|
||||
if (!radarFrames.length && !satelliteFrames.length) {
|
||||
console.warn('No radar or satellite frames available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (layerBtn) {
|
||||
layerBtn.disabled = !(radarFrames.length && satelliteFrames.length);
|
||||
@@ -463,9 +600,27 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
|
||||
buildTimeline();
|
||||
const frames = activeFrames();
|
||||
showFrame(frames.length - 1);
|
||||
showFrame(Math.max(frames.length - 1, 0));
|
||||
framesReady = true;
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('Radar load failed', e);
|
||||
retries++;
|
||||
if (e.name === 'AbortError') {
|
||||
console.warn(`Radar load attempt ${retries} timed out - API slow or unavailable`);
|
||||
} else {
|
||||
console.error(`Radar load attempt ${retries} failed:`, e.message);
|
||||
}
|
||||
|
||||
if (retries >= maxRetries) {
|
||||
console.error('Radar load failed after', maxRetries, 'attempts');
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loadingFrames = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,8 +636,32 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
function isMapVisible() {
|
||||
return mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!isMapVisible()) return;
|
||||
map.invalidateSize();
|
||||
loadFrames();
|
||||
return { map, panTo, startPlay, stopPlay };
|
||||
}
|
||||
|
||||
// Wait for visibility before first load to avoid zero-size Leaflet rendering.
|
||||
setTimeout(() => {
|
||||
let tries = 0;
|
||||
const maxTries = 40; // ~10 seconds at 250ms interval
|
||||
const timer = setInterval(() => {
|
||||
tries++;
|
||||
if (isMapVisible()) {
|
||||
refresh();
|
||||
clearInterval(timer);
|
||||
} else if (tries >= maxTries) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 250);
|
||||
}, 100);
|
||||
|
||||
return { map, panTo, startPlay, stopPlay, refresh };
|
||||
}
|
||||
|
||||
const radarEmbed = createRadar(
|
||||
@@ -562,30 +741,45 @@ function renderLightningStrikes(city) {
|
||||
if (!mapsOverview.map || !state.showLightning) return;
|
||||
clearLightningMarkers();
|
||||
|
||||
// Simulate lightning strikes in the vicinity of the city (for now, use random placement)
|
||||
// In a production app, you'd fetch from a lightning API like Blitzortung or similar
|
||||
// For demo: always show a few sample strikes so the overlay is visible when enabled.
|
||||
const strokeCount = 3;
|
||||
// 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.PI * 2 * i) / strokeCount;
|
||||
const radius = 0.18 + i * 0.06;
|
||||
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: 4,
|
||||
radius: 3 + Math.random() * 3,
|
||||
fillColor: '#ffeb3b',
|
||||
fillOpacity: 0.8,
|
||||
fillOpacity: 0.7 + Math.random() * 0.3,
|
||||
color: '#ff6f00',
|
||||
weight: 1.5,
|
||||
className: 'lightning-marker',
|
||||
}).bindPopup('Sample lightning overlay');
|
||||
}).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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,45 +789,70 @@ function renderWindArrows(city) {
|
||||
|
||||
// Get current wind data
|
||||
const current = state.currentWeather.current;
|
||||
if (!current || !current.wind_speed_10m) return;
|
||||
if (!current || current.wind_speed_10m == null) return;
|
||||
|
||||
const windSpeed = current.wind_speed_10m;
|
||||
const windGust = current.wind_gusts_10m || 0;
|
||||
const windDir = current.wind_direction_10m || 0;
|
||||
|
||||
// Draw a wind barb pointing in the direction of wind movement
|
||||
// Update wind data display
|
||||
document.getElementById('wind-speed').textContent = `${speedDisplay(windSpeed)} ${speedUnit()}`;
|
||||
document.getElementById('wind-gust').textContent = windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--';
|
||||
document.getElementById('wind-direction').textContent = compassDirFull(windDir);
|
||||
|
||||
// Draw multiple wind barbs around the city center to show wind strength
|
||||
const lat = parseFloat(city.latitude);
|
||||
const lon = parseFloat(city.longitude);
|
||||
const radius = 0.3;
|
||||
const arrowCount = Math.min(Math.ceil(windSpeed / 5), 5); // More arrows = stronger wind
|
||||
const baseRadius = 0.15;
|
||||
|
||||
for (let i = 0; i < arrowCount; i++) {
|
||||
const offset = (i - arrowCount / 2) * 0.08;
|
||||
const offsetLat = lat + offset;
|
||||
const offsetLon = lon;
|
||||
|
||||
// Calculate arrow end point based on wind direction
|
||||
const radians = (windDir * Math.PI) / 180;
|
||||
const endLat = lat + radius * Math.cos(radians);
|
||||
const endLon = lon + radius * Math.sin(radians);
|
||||
const arrowLength = 0.15 + i * 0.05;
|
||||
const endLat = offsetLat + arrowLength * Math.cos(radians);
|
||||
const endLon = offsetLon + arrowLength * Math.sin(radians);
|
||||
|
||||
// Draw arrow line with gradient color based on wind speed
|
||||
const color = windSpeed > 20 ? '#ff4444' : windSpeed > 10 ? '#ffaa00' : '#4a90e2';
|
||||
const weight = 1 + (i % 2);
|
||||
|
||||
// Draw arrow line
|
||||
const arrow = L.polyline(
|
||||
[[lat, lon], [endLat, endLon]],
|
||||
[[offsetLat, offsetLon], [endLat, endLon]],
|
||||
{
|
||||
color: '#4a90e2',
|
||||
weight: 2,
|
||||
color: color,
|
||||
weight: weight,
|
||||
opacity: 0.7,
|
||||
smooth: true,
|
||||
}
|
||||
).bindPopup(`Wind: ${speedDisplay(windSpeed)} ${speedUnit()}`);
|
||||
).bindPopup(`💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}<br>Direction: ${compassDirFull(windDir)}`);
|
||||
|
||||
arrow.addTo(mapsOverview.map);
|
||||
state.windArrows.push(arrow);
|
||||
|
||||
// Draw arrow head as a small circle at the end
|
||||
const arrowHead = L.circleMarker([endLat, endLon], {
|
||||
radius: 3,
|
||||
fillColor: '#4a90e2',
|
||||
fillOpacity: 0.7,
|
||||
color: '#fff',
|
||||
weight: 1,
|
||||
});
|
||||
// Draw arrow head
|
||||
const headSize = 0.03;
|
||||
const arrowHeadLat1 = endLat - headSize * Math.cos(radians + Math.PI * 0.15);
|
||||
const arrowHeadLon1 = endLon - headSize * Math.sin(radians + Math.PI * 0.15);
|
||||
const arrowHeadLat2 = endLat - headSize * Math.cos(radians - Math.PI * 0.15);
|
||||
const arrowHeadLon2 = endLon - headSize * Math.sin(radians - Math.PI * 0.15);
|
||||
|
||||
const arrowHead = L.polyline(
|
||||
[[arrowHeadLat1, arrowHeadLon1], [endLat, endLon], [arrowHeadLat2, arrowHeadLon2]],
|
||||
{
|
||||
color: color,
|
||||
weight: weight,
|
||||
opacity: 0.7,
|
||||
}
|
||||
);
|
||||
|
||||
arrowHead.addTo(mapsOverview.map);
|
||||
state.windArrows.push(arrowHead);
|
||||
}
|
||||
}
|
||||
|
||||
const mapsOverview = createOverviewMap(document.getElementById('map-overview'));
|
||||
@@ -662,18 +881,37 @@ function renderWeather(data, city) {
|
||||
document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]);
|
||||
document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation';
|
||||
document.getElementById('cur-hum').textContent = c.relative_humidity_2m;
|
||||
document.getElementById('cur-wind').textContent = `${speedDisplay(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`;
|
||||
document.getElementById('hum-feel').textContent = humidityFeel(c.relative_humidity_2m);
|
||||
document.getElementById('cur-wind').textContent = speedDisplay(c.wind_speed_10m);
|
||||
document.getElementById('cur-wind-dir').textContent = compassDirFull(c.wind_direction_10m);
|
||||
document.getElementById('cur-gust').textContent = c.wind_gusts_10m != null ? speedDisplay(c.wind_gusts_10m) : '—';
|
||||
document.getElementById('cur-dew').textContent = calcDewpoint(c.temperature_2m, c.relative_humidity_2m);
|
||||
document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure);
|
||||
document.getElementById('cur-vis').textContent = visDisplay(c.visibility);
|
||||
document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0);
|
||||
document.getElementById('cur-cloud').textContent = c.cloud_cover;
|
||||
document.querySelector('.stat:nth-child(2) .unit').textContent = speedUnit();
|
||||
document.querySelector('.stat:nth-child(4) .unit').textContent = visUnit();
|
||||
document.querySelectorAll('.wind-unit, .gust-unit').forEach(el => el.textContent = speedUnit());
|
||||
document.querySelectorAll('.vis-unit').forEach(el => el.textContent = visUnit());
|
||||
|
||||
document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]);
|
||||
document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]);
|
||||
document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]);
|
||||
|
||||
// Sun timeline progress
|
||||
const sunriseMs = new Date(d.sunrise[0]).getTime();
|
||||
const sunsetMs = new Date(d.sunset[0]).getTime();
|
||||
const nowMs = Date.now();
|
||||
const totalMs = sunsetMs - sunriseMs;
|
||||
const sunPct = Math.max(0, Math.min(100, ((nowMs - sunriseMs) / totalMs) * 100));
|
||||
const progressEl = document.getElementById('sun-progress');
|
||||
const dotEl = document.getElementById('sun-dot');
|
||||
if (progressEl) progressEl.style.width = sunPct + '%';
|
||||
if (dotEl) dotEl.style.left = sunPct + '%';
|
||||
const daylightEl = document.getElementById('daylight-info');
|
||||
if (daylightEl) daylightEl.textContent = `${(totalMs / 3600000).toFixed(1)}h daylight`;
|
||||
|
||||
renderRecommendations(data);
|
||||
|
||||
const nowTs = Date.now();
|
||||
let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs);
|
||||
if (startIdx < 0) startIdx = 0;
|
||||
@@ -695,21 +933,8 @@ function renderWeather(data, city) {
|
||||
track.appendChild(cell);
|
||||
}
|
||||
|
||||
const list = document.getElementById('daily-list');
|
||||
list.innerHTML = '';
|
||||
for (let i = 0; i < d.time.length; i++) {
|
||||
const dw = wmo(d.weather_code[i]);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'day-row' + (i === 0 ? ' today' : '');
|
||||
row.innerHTML = `
|
||||
<div class="day-name">${formatDayShort(d.time[i])}</div>
|
||||
<div class="day-icon">${dw.icon}</div>
|
||||
<div class="day-desc">${dw.desc}</div>
|
||||
<div class="day-temps">${tempDisplay(d.temperature_2m_max[i])}°<span class="day-lo"> / ${tempDisplay(d.temperature_2m_min[i])}°</span></div>
|
||||
<div class="day-meta">${d.precipitation_probability_max[i] > 0 ? d.precipitation_probability_max[i] + '% rain<br>' : ''}${speedDisplay(d.wind_speed_10m_max[i])} ${speedUnit()}</div>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
state.showExtendedForecast = false;
|
||||
renderDailyForecast(d);
|
||||
|
||||
document.getElementById('updated-at').textContent =
|
||||
new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
@@ -724,6 +949,43 @@ function renderWeather(data, city) {
|
||||
refreshMapsOverview();
|
||||
|
||||
show(weatherDiv);
|
||||
setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80);
|
||||
}
|
||||
|
||||
function renderDailyForecast(daily) {
|
||||
const list = document.getElementById('daily-list');
|
||||
if (!list || !daily?.time?.length) return;
|
||||
|
||||
const totalDays = daily.time.length;
|
||||
const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays);
|
||||
|
||||
list.innerHTML = '';
|
||||
for (let i = 0; i < maxDays; i++) {
|
||||
const dw = wmo(daily.weather_code[i]);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'day-row' + (i === 0 ? ' today' : '');
|
||||
row.innerHTML = `
|
||||
<div class="day-name">${formatDayShort(daily.time[i])}</div>
|
||||
<div class="day-icon">${dw.icon}</div>
|
||||
<div class="day-desc">${dw.desc}</div>
|
||||
<div class="day-temps">${tempDisplay(daily.temperature_2m_max[i])}°<span class="day-lo"> / ${tempDisplay(daily.temperature_2m_min[i])}°</span></div>
|
||||
<div class="day-meta">${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain<br>' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}</div>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
if (dailyForecastLabel) {
|
||||
dailyForecastLabel.textContent = state.showExtendedForecast ? '16-Day Forecast' : '7-Day Forecast';
|
||||
}
|
||||
|
||||
if (dailyToggleBtn) {
|
||||
if (totalDays > 7) {
|
||||
show(dailyToggleBtn);
|
||||
dailyToggleBtn.textContent = state.showExtendedForecast ? 'Show 7-day' : 'Show 16-day';
|
||||
} else {
|
||||
hide(dailyToggleBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAirQuality(data) {
|
||||
@@ -782,22 +1044,64 @@ async function loadAll(city, source) {
|
||||
show(loadingEl);
|
||||
|
||||
try {
|
||||
const [weather, air, alerts] = await Promise.all([
|
||||
fetchWeather(city.latitude, city.longitude, city.timezone),
|
||||
let weather;
|
||||
try {
|
||||
weather = await fetchWeather(city.latitude, city.longitude, city.timezone);
|
||||
} catch (weatherErr) {
|
||||
console.error('Primary weather fetch failed:', weatherErr?.message || weatherErr);
|
||||
if (state.currentWeather) {
|
||||
weather = state.currentWeather;
|
||||
showErr('Live weather update failed. Showing last available data.');
|
||||
} else {
|
||||
throw weatherErr;
|
||||
}
|
||||
}
|
||||
|
||||
const [airResult, alertsResult] = await Promise.allSettled([
|
||||
fetchAirQuality(city.latitude, city.longitude, city.timezone),
|
||||
fetchAlerts(city.latitude, city.longitude).catch(() => ({ features: [] })),
|
||||
fetchAlerts(city.latitude, city.longitude),
|
||||
]);
|
||||
|
||||
const air = airResult.status === 'fulfilled' ? airResult.value : { current: {} };
|
||||
if (airResult.status !== 'fulfilled') {
|
||||
console.warn('Air quality fetch failed:', airResult.reason?.message || airResult.reason);
|
||||
}
|
||||
|
||||
const alerts = alertsResult.status === 'fulfilled' ? alertsResult.value : { features: [] };
|
||||
if (alertsResult.status !== 'fulfilled') {
|
||||
console.warn('Alerts fetch failed:', alertsResult.reason?.message || alertsResult.reason);
|
||||
}
|
||||
|
||||
state.currentWeather = weather;
|
||||
state.currentAir = air;
|
||||
state.lastRefreshAt = Date.now();
|
||||
renderWeather(weather, city);
|
||||
renderAirQuality(air);
|
||||
renderAlerts(alerts);
|
||||
|
||||
// Render map overlays
|
||||
// Keep weather load resilient: map/radar/render hiccups should not fail the whole page.
|
||||
try {
|
||||
renderWeather(weather, city);
|
||||
} catch (renderErr) {
|
||||
console.error('Weather render error:', renderErr);
|
||||
show(weatherDiv);
|
||||
}
|
||||
|
||||
try {
|
||||
renderAirQuality(air);
|
||||
} catch (airRenderErr) {
|
||||
console.warn('Air quality render error:', airRenderErr);
|
||||
}
|
||||
|
||||
try {
|
||||
renderAlerts(alerts);
|
||||
} catch (alertsRenderErr) {
|
||||
console.warn('Alerts render error:', alertsRenderErr);
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.showLightning) renderLightningStrikes(city);
|
||||
if (state.showWind) renderWindArrows(city);
|
||||
} catch (mapOverlayErr) {
|
||||
console.warn('Map overlay render error:', mapOverlayErr);
|
||||
}
|
||||
} catch (e) {
|
||||
showErr('Failed to load weather data. Please try again.');
|
||||
console.error(e);
|
||||
@@ -859,7 +1163,8 @@ searchInput.addEventListener('input', () => {
|
||||
suggestEl.appendChild(li);
|
||||
});
|
||||
show(suggestEl);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn('Autocomplete geocode failed:', err?.message || err);
|
||||
hide(suggestEl);
|
||||
}
|
||||
}, 300);
|
||||
@@ -915,6 +1220,11 @@ unitsBtn.addEventListener('click', () => {
|
||||
if (state.currentWeather && state.currentCity) {
|
||||
renderWeather(state.currentWeather, state.currentCity);
|
||||
renderAirQuality(state.currentAir);
|
||||
// Update maps overlays if they're visible
|
||||
if (state.showWind && mapsOverview.map) {
|
||||
clearWindArrows();
|
||||
renderWindArrows(state.currentCity);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -979,7 +1289,6 @@ autoDetectToggle.addEventListener('change', () => {
|
||||
lightningToggle.addEventListener('change', () => {
|
||||
state.showLightning = lightningToggle.checked;
|
||||
localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
|
||||
lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
|
||||
if (state.currentCity && mapsOverview.map) {
|
||||
clearLightningMarkers();
|
||||
if (state.showLightning) renderLightningStrikes(state.currentCity);
|
||||
@@ -989,13 +1298,55 @@ lightningToggle.addEventListener('change', () => {
|
||||
windToggle.addEventListener('change', () => {
|
||||
state.showWind = windToggle.checked;
|
||||
localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
|
||||
windStatus.textContent = state.showWind ? 'On' : 'Off';
|
||||
if (state.currentCity && mapsOverview.map) {
|
||||
clearWindArrows();
|
||||
if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
|
||||
}
|
||||
});
|
||||
|
||||
// Maps tab quick toggles
|
||||
const mapsLightningToggle = document.getElementById('maps-lightning-toggle');
|
||||
const mapsWindToggle = document.getElementById('maps-wind-toggle');
|
||||
|
||||
if (mapsLightningToggle) {
|
||||
mapsLightningToggle.addEventListener('click', () => {
|
||||
state.showLightning = !state.showLightning;
|
||||
localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
|
||||
mapsLightningToggle.classList.toggle('active', state.showLightning);
|
||||
lightningToggle.checked = state.showLightning;
|
||||
if (state.currentCity && mapsOverview.map) {
|
||||
clearLightningMarkers();
|
||||
if (state.showLightning) renderLightningStrikes(state.currentCity);
|
||||
}
|
||||
});
|
||||
// Set initial state
|
||||
mapsLightningToggle.classList.toggle('active', state.showLightning);
|
||||
}
|
||||
|
||||
if (mapsWindToggle) {
|
||||
mapsWindToggle.addEventListener('click', () => {
|
||||
state.showWind = !state.showWind;
|
||||
localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
|
||||
mapsWindToggle.classList.toggle('active', state.showWind);
|
||||
windToggle.checked = state.showWind;
|
||||
if (state.currentCity && mapsOverview.map) {
|
||||
clearWindArrows();
|
||||
if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
|
||||
}
|
||||
});
|
||||
// Set initial state
|
||||
mapsWindToggle.classList.toggle('active', state.showWind);
|
||||
}
|
||||
|
||||
if (dailyToggleBtn) {
|
||||
dailyToggleBtn.addEventListener('click', () => {
|
||||
state.showExtendedForecast = !state.showExtendedForecast;
|
||||
if (state.currentWeather) {
|
||||
renderDailyForecast(state.currentWeather.daily);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
clearInterval(state.autoRefreshTimer);
|
||||
state.autoRefreshTimer = setInterval(() => {
|
||||
@@ -1020,9 +1371,7 @@ function startAutoRefresh() {
|
||||
|
||||
// Sync lightning and wind toggle states
|
||||
lightningToggle.checked = state.showLightning;
|
||||
lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
|
||||
windToggle.checked = state.showWind;
|
||||
windStatus.textContent = state.showWind ? 'On' : 'Off';
|
||||
|
||||
renderSavedCities();
|
||||
startAutoRefresh();
|
||||
|
||||
+31
-7
@@ -1,8 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const CACHE = 'weather-v1';
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data === 'skipWaiting') self.skipWaiting();
|
||||
});
|
||||
|
||||
// App shell — cached on install for offline use
|
||||
const CACHE = 'weather-v5';
|
||||
|
||||
// App shell cached on install for offline resilience.
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/app.js',
|
||||
@@ -22,9 +26,7 @@ self.addEventListener('install', event => {
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(k => k !== CACHE).map(k => caches.delete(k))
|
||||
))
|
||||
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
@@ -33,7 +35,7 @@ self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET, API calls, and map/radar tile requests
|
||||
// Never intercept API/tile traffic.
|
||||
if (
|
||||
request.method !== 'GET' ||
|
||||
url.pathname.startsWith('/api/') ||
|
||||
@@ -45,11 +47,33 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
const isShell =
|
||||
request.mode === 'navigate' ||
|
||||
request.destination === 'document' ||
|
||||
request.destination === 'script' ||
|
||||
request.destination === 'style';
|
||||
|
||||
if (isShell) {
|
||||
// Network-first keeps app updates fresh; cache as fallback.
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE).then(cache => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request).then(cached => cached || caches.match('/')))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets.
|
||||
event.respondWith(
|
||||
caches.match(request).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then(response => {
|
||||
// Only cache successful same-origin and trusted CDN responses
|
||||
if (response.ok && (
|
||||
url.origin === self.location.origin ||
|
||||
url.hostname.includes('googleapis') ||
|
||||
|
||||
+162
-12
@@ -3,18 +3,88 @@ const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const weatherCache = new Map();
|
||||
|
||||
// Keep shell assets fresh so SW/app versions do not drift.
|
||||
app.use('/', (req, res, next) => {
|
||||
if (req.path === '/' || req.path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
} else if (
|
||||
req.path.endsWith('.css') ||
|
||||
req.path.endsWith('.js') ||
|
||||
req.path === '/sw.js' ||
|
||||
req.path === '/manifest.json'
|
||||
) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Prevent browser favicon requests from showing noisy 404s.
|
||||
app.get('/favicon.ico', (_req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Geocoding proxy
|
||||
app.get('/api/geocode', async (req, res) => {
|
||||
const { q } = req.query;
|
||||
if (!q) return res.status(400).json({ error: 'Missing query' });
|
||||
try {
|
||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
|
||||
const r = await fetch(url);
|
||||
const data = await r.json();
|
||||
res.json(data);
|
||||
|
||||
let openMeteoData = null;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 9000);
|
||||
try {
|
||||
const r = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`);
|
||||
openMeteoData = await r.json();
|
||||
break;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (attempt === 2) {
|
||||
console.warn('Open-Meteo geocode failed after retries:', err.message);
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, 300 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openMeteoData?.results?.length) {
|
||||
return res.json(openMeteoData);
|
||||
}
|
||||
|
||||
// Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable.
|
||||
const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' });
|
||||
const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
|
||||
headers: {
|
||||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!nom.ok) {
|
||||
throw new Error(`Nominatim geocode status ${nom.status}`);
|
||||
}
|
||||
|
||||
const nomData = await nom.json();
|
||||
const results = (nomData || []).map(item => {
|
||||
const address = item.address || {};
|
||||
return {
|
||||
name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown',
|
||||
latitude: Number(item.lat),
|
||||
longitude: Number(item.lon),
|
||||
country: address.country || '',
|
||||
admin1: address.state || address.region || address.county || '',
|
||||
timezone: 'auto',
|
||||
};
|
||||
}).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude));
|
||||
|
||||
return res.json({ results });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
|
||||
}
|
||||
@@ -69,13 +139,14 @@ app.get('/api/weather', async (req, res) => {
|
||||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||
const timezone = tz || 'auto';
|
||||
const useMetric = units === 'metric';
|
||||
const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
latitude: lat, longitude: lon, timezone,
|
||||
current: [
|
||||
'temperature_2m','relative_humidity_2m','apparent_temperature',
|
||||
'is_day','precipitation','weather_code','cloud_cover',
|
||||
'wind_speed_10m','wind_direction_10m','surface_pressure','visibility'
|
||||
'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
|
||||
].join(','),
|
||||
hourly: [
|
||||
'temperature_2m','precipitation_probability','weather_code',
|
||||
@@ -91,10 +162,44 @@ app.get('/api/weather', async (req, res) => {
|
||||
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
|
||||
precipitation_unit: useMetric ? 'mm' : 'inch'
|
||||
});
|
||||
const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
|
||||
const data = await r.json();
|
||||
res.json(data);
|
||||
|
||||
let lastError = null;
|
||||
const upstreamTimeoutMs = 12000;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
|
||||
try {
|
||||
const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
const body = await r.text();
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(body);
|
||||
weatherCache.set(cacheKey, { data, at: Date.now() });
|
||||
return res.json(data);
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
lastError = err;
|
||||
const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error');
|
||||
console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`);
|
||||
if (attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Weather upstream failed');
|
||||
} catch (e) {
|
||||
const cached = weatherCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
|
||||
res.setHeader('X-Weather-Cache', 'stale-fallback');
|
||||
console.warn('Weather proxy using stale cache for', cacheKey);
|
||||
return res.json(cached.data);
|
||||
}
|
||||
console.error('Weather proxy failed:', e.message);
|
||||
res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
|
||||
}
|
||||
});
|
||||
@@ -111,11 +216,34 @@ app.get('/api/air-quality', async (req, res) => {
|
||||
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
|
||||
forecast_days: 1,
|
||||
});
|
||||
const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
|
||||
let lastError = null;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!r.ok) {
|
||||
throw new Error(`Open-Meteo status ${r.status}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
res.json(data);
|
||||
return res.json(data);
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
lastError = err;
|
||||
if (attempt < 2) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Air quality upstream failed');
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
|
||||
console.error('Air quality fetch error:', e);
|
||||
res.setHeader('X-Air-Quality-Fallback', 'empty');
|
||||
res.json({ current: {} });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -124,26 +252,48 @@ app.get('/api/alerts', async (req, res) => {
|
||||
const { lat, lon } = req.query;
|
||||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||
|
||||
const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
|
||||
headers: {
|
||||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||
Accept: 'application/geo+json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error(`NWS API returned ${r.status}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Alerts fetch failed', detail: e.message });
|
||||
console.error('Alerts fetch error:', e.message);
|
||||
res.setHeader('X-Alerts-Fallback', 'empty');
|
||||
res.json({ features: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// RainViewer timestamps proxy
|
||||
app.get('/api/radar/times', async (req, res) => {
|
||||
try {
|
||||
const r = await fetch('https://api.rainviewer.com/public/weather-maps.json');
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 25000);
|
||||
|
||||
const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
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);
|
||||
res.status(500).json({ error: 'Radar times failed', detail: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user