From e397f11285ab378c3fbde44533ca23f82ee2321d Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 16 Mar 2026 09:40:48 -0400 Subject: [PATCH] Initial commit --- README.md | 74 +++++++ app/.dockerignore | 5 + app/Dockerfile | 17 ++ app/package.json | 16 ++ app/public/app.js | 403 +++++++++++++++++++++++++++++++++++ app/public/index.html | 138 ++++++++++++ app/public/style.css | 479 ++++++++++++++++++++++++++++++++++++++++++ app/server.js | 69 ++++++ docker-compose.yml | 15 ++ 9 files changed, 1216 insertions(+) create mode 100644 README.md create mode 100644 app/.dockerignore create mode 100644 app/Dockerfile create mode 100644 app/package.json create mode 100644 app/public/app.js create mode 100644 app/public/index.html create mode 100644 app/public/style.css create mode 100644 app/server.js create mode 100644 docker-compose.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e09a48 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Weather App + +Self-hosted weather app with animated radar. No API keys required. + +## Data Sources +- **Weather**: [Open-Meteo](https://open-meteo.com) — free, no key +- **Radar**: [RainViewer](https://www.rainviewer.com) — free, no key +- **Map tiles**: CartoDB Dark Matter (free, no key) + +## Features +- Current conditions (temp, feels-like, wind, humidity, pressure, visibility, UV, clouds) +- 24-hour hourly forecast +- 7-day daily forecast +- **Embedded radar panel** on the conditions page +- **Full-page radar tab** with animated past frames + nowcast +- Animated radar timeline with play/pause controls +- City search with autocomplete + GPS geolocation +- Auto-pans radar to searched city +- Dark mode (follows system preference) +- Responsive / mobile-friendly + +## Quick Start + +```bash +docker compose up -d +``` + +App runs at **http://localhost:3000** + +## Change the port + +Edit `docker-compose.yml`: +```yaml +ports: + - "8080:3000" +``` + +## Run without Docker + +```bash +cd app +npm install +node server.js +``` + +## Project structure + +``` +weather-app/ +├── docker-compose.yml +└── app/ + ├── Dockerfile + ├── package.json + ├── server.js # Express — proxies Open-Meteo, RainViewer + └── public/ + ├── index.html + ├── style.css + └── app.js # Weather render + dual Leaflet radar +``` + +## Nginx reverse proxy + +```nginx +server { + listen 80; + server_name weather.yourdomain.com; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..5bf1e41 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +.git +.gitignore +*.md diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..9c765ba --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json . +RUN npm install --omit=dev + +COPY . . + +ENV PORT=3000 +ENV NODE_ENV=production + +EXPOSE 3000 + +USER node + +CMD ["node", "server.js"] diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..2c615f6 --- /dev/null +++ b/app/package.json @@ -0,0 +1,16 @@ +{ + "name": "weather-app", + "version": "2.0.0", + "description": "Self-hosted weather app with radar — Open-Meteo + RainViewer", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.18.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/app/public/app.js b/app/public/app.js new file mode 100644 index 0000000..0931785 --- /dev/null +++ b/app/public/app.js @@ -0,0 +1,403 @@ +'use strict'; + +// ── WMO codes ──────────────────────────────────────────────────────────────── +const WMO = { + 0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'}, + 2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'}, + 45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'}, + 51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'}, + 55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'}, + 57:{desc:'Heavy freezing drizzle',icon:'🌨️'}, + 61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'}, + 65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'}, + 67:{desc:'Heavy freezing rain',icon:'🌨️'}, + 71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'}, + 75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'}, + 80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'}, + 82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'}, + 86:{desc:'Heavy snow showers',icon:'❄️'}, + 95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'}, + 99:{desc:'Severe thunderstorm',icon:'⛈️'}, +}; + +function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; } + +const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; +const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + +// ── DOM ────────────────────────────────────────────────────────────────────── +const searchInput = document.getElementById('search-input'); +const searchBtn = document.getElementById('search-btn'); +const locateBtn = document.getElementById('locate-btn'); +const suggestEl = document.getElementById('suggestions'); +const errorMsg = document.getElementById('error-msg'); +const loadingEl = document.getElementById('loading'); +const weatherDiv = document.getElementById('weather'); + +// ── Helpers ────────────────────────────────────────────────────────────────── +const show = el => el.classList.remove('hidden'); +const hide = el => el.classList.add('hidden'); +const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); }; +const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); }; + +function compassDir(deg) { + return ['N','NE','E','SE','S','SW','W','NW'][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`; +} + +function formatDayShort(iso) { + const d = new Date(iso + 'T00:00:00'); + const today = new Date(); + const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); + if (d.toDateString() === today.toDateString()) return 'Today'; + if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow'; + return DAYS[d.getDay()]; +} + +// ── Tab switching ───────────────────────────────────────────────────────────── +document.querySelectorAll('.tab').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.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.getElementById('open-radar-tab').addEventListener('click', () => { + document.querySelector('.tab[data-tab="radar-full"]').click(); +}); + +// ── Geocoding ───────────────────────────────────────────────────────────────── +async function geocode(q) { + const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); + if (!r.ok) throw new Error('Geocoding failed'); + return r.json(); +} + +async function fetchWeather(lat, lon, tz) { + const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`); + if (!r.ok) throw new Error('Weather fetch failed'); + return r.json(); +} + +// ── Radar engine ────────────────────────────────────────────────────────────── +// Creates a self-contained animated radar on a Leaflet map +function createRadar(mapEl, timelineEl, playBtn, tsEl) { + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + // Dark basemap + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + }).addTo(map); + + let frames = []; // [{time, path, isForcast}] + let currentIdx = 0; + let layers = {}; // time → L.TileLayer + let playing = false; + let animTimer = null; + let locationMarker = null; + + function tsLabel(unixSec) { + const d = new Date(unixSec * 1000); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + ' ' + MONTHS[d.getMonth()] + ' ' + d.getDate(); + } + + function showFrame(idx) { + currentIdx = idx; + const frame = frames[idx]; + if (!frame) return; + + // Add layer if not cached + if (!layers[frame.time]) { + layers[frame.time] = L.tileLayer( + `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`, + { opacity: 0.75, maxZoom: 15 } + ); + } + + // Hide all, show current + Object.values(layers).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); }); + layers[frame.time].addTo(map); + + // Update UI + tsEl.textContent = tsLabel(frame.time); + document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => { + el.classList.toggle('active', i === idx); + }); + } + + function buildTimeline() { + timelineEl.innerHTML = ''; + frames.forEach((f, i) => { + const tick = document.createElement('div'); + tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : ''); + tick.title = tsLabel(f.time); + tick.addEventListener('click', () => { stopPlay(); showFrame(i); }); + timelineEl.appendChild(tick); + }); + } + + function step() { + const next = (currentIdx + 1) % frames.length; + showFrame(next); + if (next === 0) { + // pause at loop end briefly + stopPlay(); + setTimeout(startPlay, 800); + } + } + + function startPlay() { + playing = true; + playBtn.textContent = '⏸'; + animTimer = setInterval(step, 500); + } + + function stopPlay() { + playing = false; + playBtn.textContent = '▶'; + clearInterval(animTimer); + } + + playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay()); + + async function loadFrames() { + try { + const r = await fetch('/api/radar/times'); + const data = await r.json(); + frames = []; + + // Past frames + (data.radar?.past || []).forEach(f => frames.push({ time: f.time, path: f.path, isForecast: false })); + // Forecast frames + (data.radar?.nowcast || []).forEach(f => frames.push({ time: f.time, path: f.path, isForecast: true })); + + if (frames.length === 0) return; + + buildTimeline(); + showFrame(frames.length - 1); // show most recent + } catch (e) { + console.error('Radar load failed', e); + } + } + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (locationMarker) map.removeLayer(locationMarker); + locationMarker = L.circleMarker([lat, lon], { + radius: 6, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + loadFrames(); + + return { map, panTo, startPlay, stopPlay }; +} + +// ── Instantiate both radar instances ───────────────────────────────────────── +const radarEmbed = createRadar( + document.getElementById('radar-embed'), + document.getElementById('embed-timeline'), + document.getElementById('embed-play'), + document.getElementById('embed-timestamp') +); + +const radarFull = createRadar( + document.getElementById('radar-full'), + document.getElementById('full-timeline'), + document.getElementById('full-play'), + document.getElementById('full-timestamp') +); + +// ── Render weather data ─────────────────────────────────────────────────────── +function render(data, city) { + const c = data.current; + const h = data.hourly; + const d = data.daily; + + document.getElementById('loc-name').textContent = city.name; + document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', '); + + const now = new Date(); + document.getElementById('loc-time').innerHTML = + `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` + + now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + const w = wmo(c.weather_code); + document.getElementById('cur-icon').textContent = w.icon; + document.getElementById('cur-desc').textContent = w.desc; + document.getElementById('cur-temp').textContent = Math.round(c.temperature_2m); + document.getElementById('cur-feels').textContent = Math.round(c.apparent_temperature); + document.getElementById('cur-hi').textContent = Math.round(d.temperature_2m_max[0]); + document.getElementById('cur-lo').textContent = Math.round(d.temperature_2m_min[0]); + document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${c.precipitation.toFixed(2)}" precip` : 'No precipitation'; + document.getElementById('cur-hum').textContent = c.relative_humidity_2m; + document.getElementById('cur-wind').textContent = `${Math.round(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`; + document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure); + document.getElementById('cur-vis').textContent = c.visibility != null ? (c.visibility / 1609).toFixed(1) : '--'; + document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0); + document.getElementById('cur-cloud').textContent = c.cloud_cover; + + // Hourly + const nowHour = new Date().toISOString().slice(0, 13); + const startIdx = h.time.findIndex(t => t >= nowHour); + const track = document.getElementById('hourly-track'); + track.innerHTML = ''; + for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); i++) { + const hw = wmo(h.weather_code[i]); + const pp = h.precipitation_probability[i]; + const cell = document.createElement('div'); + cell.className = 'hour-cell' + (i === startIdx ? ' now' : ''); + cell.innerHTML = ` +
${i === startIdx ? 'Now' : formatHour(h.time[i])}
+
${hw.icon}
+
${Math.round(h.temperature_2m[i])}°
+
${pp > 0 ? pp + '%' : ''}
+ `; + track.appendChild(cell); + } + + // Daily + 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 = ` +
${formatDayShort(d.time[i])}
+
${dw.icon}
+
${dw.desc}
+
${Math.round(d.temperature_2m_max[i])}° / ${Math.round(d.temperature_2m_min[i])}°
+
${d.precipitation_probability_max[i] > 0 ? d.precipitation_probability_max[i] + '% rain
' : ''}${Math.round(d.wind_speed_10m_max[i])} mph
+ `; + list.appendChild(row); + } + + document.getElementById('updated-at').textContent = + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + // Pan both radar maps to the city + const zoom = 6; + radarEmbed.panTo(city.latitude, city.longitude, zoom); + radarFull.panTo(city.latitude, city.longitude, zoom); + + show(weatherDiv); +} + +// ── Load weather for a city ─────────────────────────────────────────────────── +async function loadWeather(city) { + clearErr(); + hide(suggestEl); + hide(weatherDiv); + show(loadingEl); + try { + const data = await fetchWeather(city.latitude, city.longitude, city.timezone); + render(data, city); + } catch (e) { + showErr('Failed to load weather data. Please try again.'); + console.error(e); + } finally { + hide(loadingEl); + } +} + +// ── Search autocomplete ─────────────────────────────────────────────────────── +let debounceTimer = null; + +searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const q = searchInput.value.trim(); + if (q.length < 2) { hide(suggestEl); return; } + debounceTimer = setTimeout(async () => { + try { + const data = await geocode(q); + const results = data.results || []; + suggestEl.innerHTML = ''; + if (!results.length) { hide(suggestEl); return; } + results.forEach(r => { + const li = document.createElement('li'); + li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`; + li.addEventListener('click', () => { + searchInput.value = r.name; + hide(suggestEl); + loadWeather(r); + }); + suggestEl.appendChild(li); + }); + show(suggestEl); + } catch { hide(suggestEl); } + }, 300); +}); + +document.addEventListener('click', e => { + if (!e.target.closest('.search-wrap')) hide(suggestEl); +}); + +function doSearch() { + const q = searchInput.value.trim(); + if (!q) { showErr('Please enter a city name.'); return; } + clearErr(); + hide(suggestEl); + geocode(q).then(data => { + const results = data.results || []; + if (!results.length) { showErr(`No results found for "${q}".`); return; } + loadWeather(results[0]); + }).catch(() => showErr('Search failed. Please try again.')); +} + +searchBtn.addEventListener('click', doSearch); +searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + +// ── Geolocation ─────────────────────────────────────────────────────────────── +locateBtn.addEventListener('click', () => { + if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; } + clearErr(); + hide(weatherDiv); + show(loadingEl); + navigator.geolocation.getCurrentPosition(async pos => { + const { latitude: lat, longitude: lon } = pos.coords; + try { + const geoData = await geocode(`${lat.toFixed(2)},${lon.toFixed(2)}`); + const city = geoData.results?.[0] || { + name: `${lat.toFixed(2)}, ${lon.toFixed(2)}`, + latitude: lat, longitude: lon, + country: '', admin1: '', timezone: 'auto' + }; + city.latitude = lat; city.longitude = lon; + const data = await fetchWeather(lat, lon, city.timezone || 'auto'); + render(data, city); + } catch (e) { + showErr('Could not fetch weather for your location.'); + } finally { + hide(loadingEl); + } + }, () => { hide(loadingEl); showErr('Location access denied.'); }); +}); + +// ── Default city ────────────────────────────────────────────────────────────── +(async () => { + try { + const data = await geocode('New York'); + const city = (data.results || [])[0]; + if (city) { searchInput.value = city.name; await loadWeather(city); } + } catch { /* silent */ } +})(); diff --git a/app/public/index.html b/app/public/index.html new file mode 100644 index 0000000..0040086 --- /dev/null +++ b/app/public/index.html @@ -0,0 +1,138 @@ + + + + + +Weather + + + + + + +
+ +
+

Weather

+ + Open-Meteo · RainViewer +
+ + +
+
+ + + +
+ + +
+ + + + + +
+ +
+ + + + +
+ + + + + diff --git a/app/public/style.css b/app/public/style.css new file mode 100644 index 0000000..b905f45 --- /dev/null +++ b/app/public/style.css @@ -0,0 +1,479 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --ink: #1a1a1a; + --ink-mid: #555; + --ink-light: #999; + --paper: #f5f2ec; + --paper-dark: #ece7db; + --paper-darker: #ddd6c7; + --border: #c8bfaf; + --accent: #c25a2a; + --cool: #2a6b8a; + --mono: 'DM Mono', monospace; + --serif: 'Playfair Display', Georgia, serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #ece7dc; + --ink-mid: #a09880; + --ink-light: #6a6258; + --paper: #191510; + --paper-dark: #201c16; + --paper-darker: #2b2620; + --border: #3a342a; + --accent: #e07848; + --cool: #5ab0d8; + } +} + +html, body { + min-height: 100%; + background: var(--paper); + color: var(--ink); + font-family: var(--mono); + font-size: 14px; + line-height: 1.6; +} + +.app { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +a { color: var(--cool); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Masthead ── */ +.masthead { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 1.5rem; + border-bottom: 1.5px solid var(--ink); + padding-bottom: 0.6rem; + margin-bottom: 1.5rem; +} + +.masthead h1 { + font-family: var(--serif); + font-size: 2.2rem; + font-weight: 400; + letter-spacing: -0.5px; +} + +.tagline { + font-size: 0.62rem; + letter-spacing: 0.12em; + color: var(--ink-light); + text-transform: uppercase; + white-space: nowrap; +} + +/* ── Tabs ── */ +.tabs { + display: flex; + gap: 0; + border: 1px solid var(--border); +} + +.tab { + background: transparent; + border: none; + border-right: 1px solid var(--border); + padding: 0.4rem 1rem; + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-mid); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.tab:last-child { border-right: none; } +.tab:hover { background: var(--paper-dark); color: var(--ink); } +.tab.active { background: var(--ink); color: var(--paper); } + +/* ── Search ── */ +.search-wrap { + position: relative; + margin-bottom: 2rem; +} + +.search-row { display: flex; } + +.search-row input { + flex: 1; + background: var(--paper-dark); + border: 1px solid var(--border); + border-right: none; + padding: 0.6rem 1rem; + font-family: var(--mono); + font-size: 0.85rem; + color: var(--ink); + outline: none; + border-radius: 0; + transition: background 0.15s; +} + +.search-row input::placeholder { color: var(--ink-light); } +.search-row input:focus { background: var(--paper-darker); } + +.search-row button { + background: var(--ink); + color: var(--paper); + border: 1px solid var(--ink); + padding: 0.6rem 1.1rem; + font-family: var(--mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + border-radius: 0; + transition: opacity 0.15s; + white-space: nowrap; +} + +.search-row button:hover { opacity: 0.72; } + +#locate-btn { + background: var(--paper-dark) !important; + color: var(--ink) !important; + border-left: none !important; + font-size: 1.2rem !important; + letter-spacing: 0 !important; + padding: 0.6rem 0.9rem !important; +} + +/* Suggestions */ +.suggestions { + position: absolute; + top: 100%; + left: 0; right: 0; + background: var(--paper-darker); + border: 1px solid var(--border); + border-top: none; + z-index: 200; + list-style: none; +} + +.suggestions li { + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.82rem; + border-bottom: 0.5px solid var(--border); + transition: background 0.1s; +} + +.suggestions li:last-child { border-bottom: none; } +.suggestions li:hover { background: var(--paper-dark); } +.sug-country { color: var(--ink-light); margin-left: 0.5rem; font-size: 0.75rem; } + +/* ── Error / Loading ── */ +.error { font-size: 0.78rem; color: var(--accent); margin-top: 0.5rem; } + +.loading { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 3rem 0; + font-size: 0.78rem; + color: var(--ink-light); + letter-spacing: 0.08em; +} + +.loading-dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--ink-light); + animation: pulse 1.2s ease-in-out infinite; +} + +@keyframes pulse { 0%,100%{opacity:.2} 50%{opacity:1} } + +.hidden { display: none !important; } + +/* ── Tab panels ── */ +.tab-panel { width: 100%; } + +/* ── Location header ── */ +.loc-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 1.5rem; + padding-bottom: 0.6rem; + border-bottom: 0.5px solid var(--border); +} + +.loc-name { font-family: var(--serif); font-size: 1.6rem; font-weight: 400; line-height: 1.1; } +.loc-sub { font-size: 0.68rem; color: var(--ink-light); letter-spacing: 0.06em; margin-top: 0.2rem; text-transform: uppercase; } +.loc-time { font-size: 0.68rem; color: var(--ink-light); text-align: right; letter-spacing: 0.04em; line-height: 1.5; } + +/* ── Current hero ── */ +.current-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + margin-bottom: 1px; +} + +.current-grid > * { background: var(--paper); padding: 1.75rem 1.5rem; } + +.temp-big { + font-family: var(--serif); + font-size: 5.5rem; + font-weight: 400; + line-height: 1; + letter-spacing: -3px; +} + +.deg { font-family: var(--mono); font-size: 1.6rem; color: var(--ink-mid); vertical-align: super; letter-spacing: 0; } +.feels { font-size: 0.72rem; color: var(--ink-mid); margin-top: 0.5rem; } +.hi-lo { font-size: 0.72rem; color: var(--ink-mid); margin-top: 0.2rem; } + +.current-cond { display: flex; flex-direction: column; justify-content: center; } +.cond-icon { font-size: 2.8rem; margin-bottom: 0.4rem; line-height: 1; } +.cond-desc { font-size: 1rem; font-weight: 500; } +.cond-sub { font-size: 0.72rem; color: var(--ink-mid); margin-top: 0.3rem; } + +/* ── Stats bar ── */ +.stats-bar { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-top: none; + margin-bottom: 2rem; +} + +.stat { background: var(--paper-dark); padding: 0.9rem 0.75rem; text-align: center; } +.stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; } +.stat-val { font-size: 1rem; font-weight: 500; } +.unit { font-size: 0.6rem; color: var(--ink-mid); margin-left: 1px; } + +/* ── Section heads ── */ +.section-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.6rem; +} + +.section-label { font-size: 0.62rem; letter-spacing: 0.14em; color: var(--ink-light); text-transform: uppercase; } +.section-note { font-size: 0.6rem; color: var(--ink-light); font-style: italic; } + +.link-btn { + background: none; + border: none; + font-family: var(--mono); + font-size: 0.6rem; + color: var(--cool); + cursor: pointer; + padding: 0; + font-style: italic; +} + +.link-btn:hover { text-decoration: underline; } + +/* ── Radar maps ── */ +.radar-embed-wrap { + border: 1px solid var(--border); + margin-bottom: 2rem; + background: #1a1f2e; +} + +.radar-map { + width: 100%; + height: 320px; +} + +.radar-map-full { + height: calc(100vh - 280px); + min-height: 420px; +} + +.radar-full-wrap { + border: 1px solid var(--border); + background: #1a1f2e; + margin-bottom: 0.5rem; +} + +/* ── Radar controls ── */ +.radar-controls { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: var(--paper-darker); + border-top: 1px solid var(--border); +} + +.radar-controls-full { padding: 0.75rem 1rem; } + +.radar-btn { + background: var(--ink); + color: var(--paper); + border: none; + width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + font-size: 0.7rem; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s; +} + +.radar-btn:hover { opacity: 0.72; } + +.radar-timeline { + flex: 1; + display: flex; + gap: 2px; + align-items: center; + height: 28px; +} + +.tl-tick { + flex: 1; + height: 100%; + background: var(--paper-dark); + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; + position: relative; +} + +.tl-tick:hover { background: var(--paper); } +.tl-tick.active { background: var(--cool); border-color: var(--cool); } +.tl-tick.forecast { opacity: 0.55; } + +.radar-ts { + font-size: 0.62rem; + color: var(--ink-mid); + letter-spacing: 0.04em; + white-space: nowrap; + min-width: 80px; + text-align: right; +} + +/* Radar legend */ +.radar-legend { + display: flex; + align-items: center; + gap: 0.4rem; + margin-left: 0.5rem; +} + +.legend-label { font-size: 0.58rem; color: var(--ink-light); letter-spacing: 0.06em; } + +.legend-bar { + width: 80px; + height: 8px; + background: linear-gradient(to right, + #00d8ff, #00a0e0, #0070d0, + #00c800, #80e000, #ffff00, + #ffc000, #ff6000, #ff0000, #c00000 + ); + border: 0.5px solid var(--border); +} + +.radar-attribution { + font-size: 0.62rem; + color: var(--ink-light); + padding-top: 0.5rem; +} + +/* ── Hourly scroll ── */ +.hourly-scroll { + overflow-x: auto; + margin-bottom: 2rem; + border: 1px solid var(--border); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.hourly-track { display: flex; min-width: max-content; } + +.hour-cell { + min-width: 72px; + padding: 0.75rem 0.5rem; + text-align: center; + border-right: 0.5px solid var(--border); + background: var(--paper); + transition: background 0.12s; +} + +.hour-cell:last-child { border-right: none; } +.hour-cell:hover { background: var(--paper-dark); } +.hour-cell.now { background: var(--paper-darker); } + +.hour-time { font-size: 0.6rem; color: var(--ink-light); letter-spacing: 0.06em; margin-bottom: 0.35rem; text-transform: uppercase; } +.hour-icon { font-size: 1.4rem; margin-bottom: 0.3rem; } +.hour-temp { font-size: 0.88rem; font-weight: 500; } +.hour-precip { font-size: 0.6rem; color: var(--cool); margin-top: 0.2rem; } + +/* ── Daily forecast ── */ +.daily-list { + border: 1px solid var(--border); + margin-bottom: 2rem; +} + +.day-row { + display: grid; + grid-template-columns: 120px 44px 1fr 80px 60px; + align-items: center; + gap: 1rem; + padding: 0.85rem 1.1rem; + border-bottom: 0.5px solid var(--border); + transition: background 0.12s; +} + +.day-row:last-child { border-bottom: none; } +.day-row:hover { background: var(--paper-dark); } +.day-row.today { background: var(--paper-dark); } + +.day-name { font-size: 0.8rem; font-weight: 500; } +.day-icon { font-size: 1.5rem; text-align: center; } +.day-desc { font-size: 0.72rem; color: var(--ink-mid); } +.day-temps { text-align: right; font-size: 0.82rem; font-weight: 500; white-space: nowrap; } +.day-lo { color: var(--ink-light); } +.day-meta { text-align: right; font-size: 0.62rem; color: var(--ink-light); line-height: 1.6; } + +/* ── Footer ── */ +.footer-note { + font-size: 0.62rem; + color: var(--ink-light); + letter-spacing: 0.04em; + border-top: 0.5px solid var(--border); + padding-top: 0.75rem; +} + +/* ── Leaflet overrides ── */ +.leaflet-container { background: #1a1f2e; } +.leaflet-control-zoom a { + background: var(--paper-dark) !important; + color: var(--ink) !important; + border-color: var(--border) !important; +} + +/* ── Responsive ── */ +@media (max-width: 640px) { + .app { padding: 1.25rem 1rem 3rem; } + .masthead { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; } + .masthead h1 { font-size: 1.6rem; } + .tagline { display: none; } + .tabs { grid-column: 1 / -1; } + .stats-bar { grid-template-columns: repeat(3, 1fr); } + .current-grid { grid-template-columns: 1fr; } + .temp-big { font-size: 4rem; } + .day-row { grid-template-columns: 80px 36px 1fr 70px; } + .day-meta { display: none; } + .radar-map { height: 240px; } + .legend-bar { width: 50px; } +} diff --git a/app/server.js b/app/server.js new file mode 100644 index 0000000..cf0ed08 --- /dev/null +++ b/app/server.js @@ -0,0 +1,69 @@ +const express = require('express'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(express.static(path.join(__dirname, 'public'))); + +// 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); + } catch (e) { + res.status(500).json({ error: 'Geocoding failed', detail: e.message }); + } +}); + +// Weather proxy +app.get('/api/weather', async (req, res) => { + const { lat, lon, tz } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + const timezone = tz || 'auto'; + 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' + ].join(','), + hourly: [ + 'temperature_2m','precipitation_probability','weather_code', + 'wind_speed_10m','apparent_temperature' + ].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: 7, + wind_speed_unit: 'mph', + temperature_unit: 'fahrenheit', + precipitation_unit: 'inch' + }); + const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); + const data = await r.json(); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Weather fetch failed', detail: e.message }); + } +}); + +// 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 data = await r.json(); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Radar times failed', detail: e.message }); + } +}); + +app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`)); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..02071ba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + weather: + build: ./app + container_name: weather-app + restart: unless-stopped + ports: + - "3000:3000" + environment: + - PORT=3000 + - NODE_ENV=production + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/"] + interval: 30s + timeout: 5s + retries: 3