WIP: Begin unified map refactor - HTML structure, storage, CSS, state updates

This commit is contained in:
Nate Emmert committed 2026-03-17 12:25:40 -04:00
1 parent f4505b5650
commit 98c45bb26f
20 files changed
+7354 -88

No files matched your search

File diff suppressed because it is too large. Load diff
+405
View File
@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>NeCloud Weather</title>
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/icon.svg">
<meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#191510" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NeCloud Weather">
<meta name="mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<header class="masthead">
<h1>NeCloud Weather</h1>
<nav class="tabs">
<button class="tab active" data-tab="conditions">Conditions</button>
<button class="tab" data-tab="radar-full">Radar</button>
<button class="tab" data-tab="maps">Maps</button>
<button class="tab" data-tab="alerts">Alerts</button>
<button class="tab" data-tab="settings">Settings</button>
</nav>
<span class="tagline">Open-Meteo · RainViewer</span>
</header>
<!-- Search bar (shared, always visible) -->
<div class="search-wrap">
<div class="search-row">
<input id="search-input" type="text" placeholder="City, region, or country…" autocomplete="off" spellcheck="false">
<button id="search-btn">Search</button>
<button id="locate-btn" title="Use my location">⌖</button>
<button id="units-btn" title="Toggle units">US</button>
</div>
<ul id="suggestions" class="suggestions hidden"></ul>
<p id="error-msg" class="error hidden"></p>
</div>
<!-- Loading -->
<div id="loading" class="loading hidden">
<div class="loading-container">
<div class="loading-icon">🌤️</div>
<div class="loading-text">Fetching weather data…</div>
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
</div>
</div>
<!-- ── TAB: CONDITIONS ── -->
<div id="tab-conditions" class="tab-panel">
<div id="weather" class="hidden">
<div class="loc-header">
<div>
<div id="loc-name" class="loc-name"></div>
<div id="loc-sub" class="loc-sub"></div>
</div>
<div class="loc-actions">
<div id="loc-time" class="loc-time"></div>
<button id="share-btn" class="link-btn" type="button">Share location</button>
</div>
</div>
<!-- ── HERO ── -->
<div class="hero">
<div class="hero-left">
<div id="cur-icon" class="hero-icon"></div>
<div id="cur-desc" class="hero-desc"></div>
<div id="cur-precip" class="hero-sub"></div>
</div>
<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>
</div>
<div class="hero-right">
<div class="hero-hilo">
<div class="hilo-item">
<span class="hilo-arrow">↑</span>
<span id="cur-hi">--</span>°
<div class="hilo-label">High</div>
</div>
<div class="hilo-divider"></div>
<div class="hilo-item">
<span class="hilo-arrow">↓</span>
<span id="cur-lo">--</span>°
<div class="hilo-label">Low</div>
</div>
</div>
</div>
</div>
<!-- ── SUN & MOON TIMELINE ── -->
<div class="sun-section">
<div class="sun-timeline-wrap">
<div class="sun-endpoint">
<span class="sun-ep-icon">🌅</span>
<span id="sunrise-val" class="sun-ep-time">--</span>
</div>
<div class="sun-track-wrap">
<div class="sun-track">
<div class="sun-progress" id="sun-progress"></div>
<div class="sun-dot" id="sun-dot">☀️</div>
</div>
</div>
<div class="sun-endpoint">
<span class="sun-ep-icon">🌇</span>
<span id="sunset-val" class="sun-ep-time">--</span>
</div>
</div>
<div class="moon-row">
<span class="moon-emoji">🌙</span>
<span id="moon-phase-val">--</span>
<span class="daylight-info" id="daylight-info">--</span>
</div>
</div>
<!-- ── WIND & ATMOSPHERE ── -->
<div class="section-head">
<span class="section-label">Wind &amp; Atmosphere</span>
</div>
<div class="card-group">
<div class="stat-card">
<div class="stat-label">Wind</div>
<div class="stat-val"><span id="cur-wind">--</span> <span class="stat-unit wind-unit">mph</span></div>
<div class="stat-sub" id="cur-wind-dir">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Gusts</div>
<div class="stat-val"><span id="cur-gust">--</span> <span class="stat-unit gust-unit">mph</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Humidity</div>
<div class="stat-val"><span id="cur-hum">--</span><span class="stat-unit">%</span></div>
<div class="stat-sub" id="hum-feel"></div>
</div>
<div class="stat-card">
<div class="stat-label">Dewpoint</div>
<div class="stat-val"><span id="cur-dew">--</span>°</div>
</div>
<div class="stat-card">
<div class="stat-label">Pressure</div>
<div class="stat-val"><span id="cur-pres">--</span> <span class="stat-unit">hPa</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Visibility</div>
<div class="stat-val"><span id="cur-vis">--</span> <span class="stat-unit vis-unit">mi</span></div>
</div>
</div>
<!-- ── MORE DETAILS DRAWER ── -->
<details class="details-drawer">
<summary class="drawer-summary">More Details <span class="drawer-chevron">›</span></summary>
<div class="drawer-body">
<div class="card-group drawer-card-group">
<div class="stat-card">
<div class="stat-label">UV Index</div>
<div class="stat-val"><span id="cur-uv">--</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Cloud Cover</div>
<div class="stat-val"><span id="cur-cloud">--</span><span class="stat-unit">%</span></div>
</div>
<div class="stat-card">
<div class="stat-label">US AQI</div>
<div class="stat-val"><span id="aqi-us">--</span></div>
</div>
<div class="stat-card">
<div class="stat-label">PM2.5</div>
<div class="stat-val"><span id="pm25">--</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Pollen</div>
<div class="stat-val"><span id="pollen">--</span></div>
</div>
</div>
</div>
</details>
<!-- ── WHAT TO BRING ── -->
<div class="section-head" style="margin-top:1.5rem">
<span class="section-label">What to Bring</span>
</div>
<div id="recommendations" class="recs-grid"></div>
<!-- Embedded radar panel -->
<div class="section-head">
<span class="section-label">Radar</span>
<span class="section-note">
<button class="link-btn" id="open-radar-tab">Open full radar →</button>
</span>
</div>
<div class="radar-embed-wrap">
<div id="radar-embed" class="radar-map"></div>
<div class="radar-controls">
<button id="embed-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
<button id="embed-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
<div class="radar-timeline" id="embed-timeline"></div>
<span id="embed-timestamp" class="radar-ts">--</span>
</div>
</div>
<!-- Hourly -->
<div class="section-head" style="margin-top:1.5rem">
<span class="section-label">Hourly Forecast</span>
<span class="section-note">next 24 hours</span>
</div>
<div id="hourly-scroll" class="hourly-scroll">
<div id="hourly-track" class="hourly-track"></div>
</div>
<!-- 7-day -->
<div class="section-head">
<span id="daily-forecast-label" class="section-label">7-Day Forecast</span>
<button id="daily-toggle-btn" class="link-btn" type="button">Show 16-day</button>
</div>
<div id="daily-list" class="daily-list"></div>
<div class="footer-note">
Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> ·
Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> ·
Updated <span id="updated-at"></span>
</div>
</div>
</div>
<!-- ── TAB: FULL RADAR ── -->
<div id="tab-radar-full" class="tab-panel hidden">
<div class="radar-full-wrap">
<div id="radar-full" class="radar-map radar-map-full"></div>
<div class="radar-controls radar-controls-full">
<button id="full-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
<button id="full-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
<div class="radar-timeline" id="full-timeline"></div>
<span id="full-timestamp" class="radar-ts">--</span>
<div class="radar-legend">
<span class="legend-label">Light</span>
<div class="legend-bar"></div>
<span class="legend-label">Heavy</span>
</div>
</div>
</div>
<p class="radar-attribution">Radar tiles © <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> · Map © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a></p>
</div>
<!-- ── TAB: MAPS ── -->
<div id="tab-maps" class="tab-panel hidden">
<div class="maps-wrap">
<div class="maps-header">
<h3>Live Weather Overlays</h3>
<div class="maps-quick-toggles">
<button id="maps-lightning-toggle" class="overlay-toggle lightning-btn" title="Toggle lightning strikes">⚡ Lightning</button>
<button id="maps-wind-toggle" class="overlay-toggle wind-btn active" title="Toggle wind overlay">💨 Wind</button>
</div>
</div>
<div id="map-overview" class="radar-map map-overview"></div>
<div class="maps-legend">
<div class="legend-section">
<div class="legend-title">⚡ Lightning Strikes</div>
<div class="legend-item">
<svg width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="5" fill="#ffeb3b" stroke="#ff6f00" stroke-width="1.5"/>
</svg>
<span>Recent strike location</span>
</div>
</div>
<div class="legend-section">
<div class="legend-title">💨 Wind Flow</div>
<div class="legend-item">
<svg width="32" height="16" viewBox="0 0 32 16">
<line x1="2" y1="8" x2="24" y2="8" stroke="#4a90e2" stroke-width="2"/>
<polygon points="24,8 20,5 20,11" fill="#4a90e2"/>
</svg>
<span>Wind direction & speed</span>
</div>
</div>
</div>
<div class="maps-data">
<div class="maps-card">
<div class="card-title">Location</div>
<div id="maps-location" class="maps-value">--</div>
</div>
<div class="maps-card wind-card">
<div class="card-title">Wind Data</div>
<div class="wind-stats">
<div class="wind-stat">
<span class="stat-label">Speed</span>
<span id="wind-speed" class="stat-value">--</span>
</div>
<div class="wind-stat">
<span class="stat-label">Gust</span>
<span id="wind-gust" class="stat-value">--</span>
</div>
<div class="wind-stat">
<span class="stat-label">Direction</span>
<span id="wind-direction" class="stat-value">--</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── TAB: ALERTS ── -->
<div id="tab-alerts" class="tab-panel hidden">
<div id="alerts-wrap" class="alerts-wrap hidden">
<div class="section-head">
<span class="section-label">Weather Alerts</span>
<button id="notify-btn" class="link-btn" type="button">Enable notifications</button>
</div>
<div id="alerts-list" class="alerts-list"></div>
</div>
<p id="alerts-empty" class="panel-note">No active alerts for this location.</p>
</div>
<!-- ── TAB: SETTINGS ── -->
<div id="tab-settings" class="tab-panel hidden">
<div class="settings-wrap">
<div class="saved-wrap">
<div class="saved-head">
<span>Saved locations</span>
<button id="save-current-btn" class="link-btn" type="button">Save current</button>
</div>
<div id="saved-locations" class="saved-locations"></div>
</div>
<div class="settings-row">
<span class="settings-label">Auto-detect location on startup</span>
<label class="switch-toggle">
<input id="auto-detect-toggle" type="checkbox" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<span class="settings-label">Data source location</span>
<span id="settings-location" class="settings-value">--</span>
</div>
<div class="settings-row">
<span class="settings-label">Units</span>
<button id="units-btn-settings" class="settings-btn" type="button">US</button>
</div>
<div class="settings-row">
<span class="settings-label">Lightning strikes on map</span>
<label class="switch-toggle">
<input id="lightning-toggle" type="checkbox">
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<span class="settings-label">Wind overlay on map</span>
<label class="switch-toggle">
<input id="wind-toggle" type="checkbox">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div><!-- .app -->
<nav class="bottom-nav" aria-label="Mobile navigation">
<button class="bottom-tab active" data-tab="conditions" type="button">
<span class="bottom-icon">⛅</span>
<span class="bottom-label">Now</span>
</button>
<button class="bottom-tab" data-tab="radar-full" type="button">
<span class="bottom-icon">📡</span>
<span class="bottom-label">Radar</span>
</button>
<button class="bottom-tab" data-tab="maps" type="button">
<span class="bottom-icon">🗺️</span>
<span class="bottom-label">Maps</span>
</button>
<button class="bottom-tab" data-tab="alerts" type="button">
<span class="bottom-icon">⚠️</span>
<span class="bottom-label">Alerts</span>
</button>
<button class="bottom-tab" data-tab="settings" type="button">
<span class="bottom-icon">⚙️</span>
<span class="bottom-label">Settings</span>
</button>
</nav>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="app.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
</script>
</body>
</html>
+286
View File
@@ -0,0 +1,286 @@
const express = require('express');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
const weatherCache = new Map();
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`;
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 });
}
});
// Reverse geocoding proxy
app.get('/api/reverse-geocode', async (req, res) => {
const { lat, lon } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
const params = new URLSearchParams({
format: 'jsonv2',
lat: String(lat),
lon: String(lon),
zoom: '10',
addressdetails: '1',
});
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/json',
},
});
if (!r.ok) {
throw new Error(`Reverse geocoding failed with status ${r.status}`);
}
const data = await r.json();
const address = data.address || {};
const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
res.json({
name,
admin1: address.state || address.region || address.county || '',
country: address.country || '',
latitude: Number(lat),
longitude: Number(lon),
timezone: 'auto',
display_name: data.display_name || '',
});
} catch (e) {
res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message });
}
});
// Weather proxy
app.get('/api/weather', async (req, res) => {
const { lat, lon, tz, units } = req.query;
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','wind_gusts_10m','surface_pressure','visibility'
].join(','),
hourly: [
'temperature_2m','precipitation_probability','weather_code',
'wind_speed_10m','apparent_temperature','rain','snowfall'
].join(','),
daily: [
'weather_code','temperature_2m_max','temperature_2m_min',
'precipitation_sum','precipitation_probability_max',
'wind_speed_10m_max','sunrise','sunset','uv_index_max'
].join(','),
forecast_days: 16,
wind_speed_unit: useMetric ? 'kmh' : 'mph',
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
precipitation_unit: useMetric ? 'mm' : 'inch'
});
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 });
}
});
// Open-Meteo Air Quality proxy
app.get('/api/air-quality', async (req, res) => {
const { lat, lon, tz } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
const params = new URLSearchParams({
latitude: lat,
longitude: lon,
timezone: tz || 'auto',
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
forecast_days: 1,
});
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();
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) {
console.error('Air quality fetch error:', e);
res.setHeader('X-Air-Quality-Fallback', 'empty');
res.json({ current: {} });
}
});
// NWS alerts proxy (US only)
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) {
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 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 });
}
});
app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`));
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
'use strict';
// Disable offline caching so UI updates are always immediate.
self.addEventListener('install', event => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
await self.clients.claim();
await self.registration.unregister();
})());
});
self.addEventListener('fetch', () => {
// Intentionally no fetch interception.
});