Initial commit
This commit is contained in:
commit
e397f11285
9 files changed
+1216
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()}<br>` +
|
||||
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 = `
|
||||
<div class="hour-time">${i === startIdx ? 'Now' : formatHour(h.time[i])}</div>
|
||||
<div class="hour-icon">${hw.icon}</div>
|
||||
<div class="hour-temp">${Math.round(h.temperature_2m[i])}°</div>
|
||||
<div class="hour-precip">${pp > 0 ? pp + '%' : ''}</div>
|
||||
`;
|
||||
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 = `
|
||||
<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">${Math.round(d.temperature_2m_max[i])}°<span class="day-lo"> / ${Math.round(d.temperature_2m_min[i])}°</span></div>
|
||||
<div class="day-meta">${d.precipitation_probability_max[i] > 0 ? d.precipitation_probability_max[i] + '% rain<br>' : ''}${Math.round(d.wind_speed_10m_max[i])} mph</div>
|
||||
`;
|
||||
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}<span class="sug-country">${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}</span>`;
|
||||
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 */ }
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Weather</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet">
|
||||
<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>Weather</h1>
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="conditions">Conditions</button>
|
||||
<button class="tab" data-tab="radar-full">Radar</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>
|
||||
</div>
|
||||
<ul id="suggestions" class="suggestions hidden"></ul>
|
||||
<p id="error-msg" class="error hidden"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading" class="loading hidden">
|
||||
<span class="loading-dot"></span>Fetching weather data…
|
||||
</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 id="loc-time" class="loc-time"></div>
|
||||
</div>
|
||||
|
||||
<!-- Current hero -->
|
||||
<div class="current-grid">
|
||||
<div class="current-temp">
|
||||
<div class="temp-big"><span id="cur-temp">--</span><span class="deg">°F</span></div>
|
||||
<div class="feels">Feels like <span id="cur-feels">--</span>°F</div>
|
||||
<div class="hi-lo">↑<span id="cur-hi">--</span>° · ↓<span id="cur-lo">--</span>°</div>
|
||||
</div>
|
||||
<div class="current-cond">
|
||||
<div id="cur-icon" class="cond-icon"></div>
|
||||
<div id="cur-desc" class="cond-desc"></div>
|
||||
<div id="cur-precip" class="cond-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-bar">
|
||||
<div class="stat"><div class="stat-label">Humidity</div><div class="stat-val"><span id="cur-hum">--</span><span class="unit">%</span></div></div>
|
||||
<div class="stat"><div class="stat-label">Wind</div><div class="stat-val"><span id="cur-wind">--</span><span class="unit">mph</span></div></div>
|
||||
<div class="stat"><div class="stat-label">Pressure</div><div class="stat-val"><span id="cur-pres">--</span><span class="unit">hPa</span></div></div>
|
||||
<div class="stat"><div class="stat-label">Visibility</div><div class="stat-val"><span id="cur-vis">--</span><span class="unit">mi</span></div></div>
|
||||
<div class="stat"><div class="stat-label">UV Index</div><div class="stat-val"><span id="cur-uv">--</span></div></div>
|
||||
<div class="stat"><div class="stat-label">Cloud Cover</div><div class="stat-val"><span id="cur-cloud">--</span><span class="unit">%</span></div></div>
|
||||
</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>
|
||||
<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 class="section-label">7-Day Forecast</span>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
</div><!-- .app -->
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}`));
|
||||
@@ -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
|
||||
Reference in new issue
Block a user