init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
29
.env.example
Normal file
29
.env.example
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Copy this file to .env.local and fill in what you have.
|
||||||
|
# Any provider without a key shows as "No key" in the picker.
|
||||||
|
# Open-Meteo needs no key, so the app always has at least one working source.
|
||||||
|
|
||||||
|
# --- OpenWeather (default provider; also powers city search and four map layers) ---
|
||||||
|
# https://home.openweathermap.org/api_keys
|
||||||
|
OPENWEATHER_API_KEY=
|
||||||
|
# free → 5 days in 3-hour steps (default)
|
||||||
|
# onecall → One Call 4.0: hourly for 48 h, daily, dew point and UV.
|
||||||
|
# Needs the separate "One Call by Call" subscription (1,000 calls/day free, card on file).
|
||||||
|
OPENWEATHER_PLAN=free
|
||||||
|
|
||||||
|
# --- WeatherAPI.com ---
|
||||||
|
# https://www.weatherapi.com/signup.aspx
|
||||||
|
WEATHERAPI_API_KEY=
|
||||||
|
# free → 3 days. Set to "paid" if your plan includes 14 days.
|
||||||
|
WEATHERAPI_PLAN=free
|
||||||
|
|
||||||
|
# --- Tomorrow.io --- (free plan: 25 calls/hour, 500/day)
|
||||||
|
# https://app.tomorrow.io/development/keys
|
||||||
|
TOMORROW_API_KEY=
|
||||||
|
|
||||||
|
# --- National Weather Service alerts (US only, no key) ---
|
||||||
|
# NWS asks apps to identify themselves. Put an email or website here.
|
||||||
|
NWS_CONTACT=
|
||||||
|
|
||||||
|
# --- CARTO Basemaps (dark background map & city labels under the radar) ---
|
||||||
|
# Free key (5M tiles/month, no password, emailed in ~1 min): https://carto.com/basemaps/apikey
|
||||||
|
CARTO_API_KEY=
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
!.env.example
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
120
README.md
Normal file
120
README.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Wayfarer
|
||||||
|
|
||||||
|
A black-and-gold weather station: pixel-art skies, a heart-based comfort meter,
|
||||||
|
hourly and 14-day forecasts you can switch between, an animated radar map,
|
||||||
|
official alerts and air quality, and a switchable data source.
|
||||||
|
Built with Next.js 16 (App Router), TypeScript, Tailwind CSS v4 and Leaflet.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env.local # add whichever keys you have
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
With no keys at all, everything except the OpenWeather map layers still works,
|
||||||
|
on Open-Meteo.
|
||||||
|
|
||||||
|
## What it shows
|
||||||
|
|
||||||
|
| Section | What's in it |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Alerts | Official NWS watches and warnings for US places; provider alerts elsewhere (WeatherAPI only). Each opens to show the full text. |
|
||||||
|
| Hero | Temperature, conditions, today's high/low, a precipitation outlook for the next 24 h, comfort hearts. |
|
||||||
|
| Conditions | Feels like, dew point, humidity, wind with direction and gusts, UV index, air quality (US AQI + PM2.5), pressure, visibility, cloud cover, precipitation, sun times, moon phase. |
|
||||||
|
| Hourly | 24 h / 48 h / 7-day chart (temperature, feels like, chance of precipitation) with a crosshair readout of any hour, or a full table. |
|
||||||
|
| Daily | Up to 14 days with a range bar per day. Tap a day for precipitation, wind, UV, humidity, sunrise, sunset and daylight. |
|
||||||
|
| Radar and maps | Animated radar (worldwide or high-detail US), plus OpenWeather precipitation, cloud, temperature, wind and pressure maps. |
|
||||||
|
|
||||||
|
A reading a provider doesn't offer is simply hidden. Nothing shows a fake zero.
|
||||||
|
|
||||||
|
## Providers and what each delivers
|
||||||
|
|
||||||
|
| Provider | Env vars | Hourly | Daily | Notes |
|
||||||
|
| ----------- | ---------------------------------------- | ----------------- | ----------------- | ------------------------------------------------------------------- |
|
||||||
|
| Open-Meteo | none | 7 days, 1 h steps | **14 days** | Free, no key. The deepest data here. |
|
||||||
|
| OpenWeather | `OPENWEATHER_API_KEY`, `OPENWEATHER_PLAN`| 5 days, 3 h steps | 5 days | `free` plan. No dew point or UV. |
|
||||||
|
| | | 48 h, 1 h steps | 8+ days | `onecall` plan (One Call 4.0). Separate subscription, 1,000 calls/day free. |
|
||||||
|
| WeatherAPI | `WEATHERAPI_API_KEY`, `WEATHERAPI_PLAN` | up to 3 days | 3 days (14 paid) | Includes its own alerts. |
|
||||||
|
| Tomorrow.io | `TOMORROW_API_KEY` | 5 days | 6 days | Free plan: 25 calls/hour. Responses are cached for 10 minutes. |
|
||||||
|
|
||||||
|
Every provider also gets these, from free sources:
|
||||||
|
|
||||||
|
- **Air quality and time zone:** Open-Meteo's air-quality API (CAMS data), so the AQI scale is the same whichever provider you pick.
|
||||||
|
- **US alerts:** the National Weather Service. Set `NWS_CONTACT` to an email or URL, because NWS asks apps to identify themselves.
|
||||||
|
- **Moon phase:** calculated locally.
|
||||||
|
|
||||||
|
## Radar sources
|
||||||
|
|
||||||
|
| Layer | Source | Coverage | Frames | Limits |
|
||||||
|
| ------------- | ------------------------------ | --------- | ----------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| Radar | RainViewer | Worldwide | Last hour, 10-min steps | Max zoom 7, past frames only, **personal/educational use only** (since Jan 2026) |
|
||||||
|
| US radar | NWS NEXRAD via Iowa State IEM | US only | Last 50 min, 5-min steps | Shared academic service: fine for personal use, not for thousands of users |
|
||||||
|
| OpenWeather × 5 | OpenWeather map tiles | Worldwide | Current only | Needs `OPENWEATHER_API_KEY`; tiles are proxied by `/api/tiles` so the key stays secret |
|
||||||
|
|
||||||
|
US places open on the US radar layer automatically. The basemap is CARTO's dark style;
|
||||||
|
set `CARTO_API_KEY` (free at `carto.com/basemaps/apikey`) so `/api/tiles` can strip CARTO's
|
||||||
|
watermark while keeping the key server-side.
|
||||||
|
|
||||||
|
## How it fits together
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Server (Next.js route handlers) Outside
|
||||||
|
─────── ─────────────────────────────── ───────
|
||||||
|
WeatherApp ── /api/providers ──▶ which keys exist + each plan's limits
|
||||||
|
── /api/geocode ──▶ lib/weather/geocode.ts ─────────────────────▶ OpenWeather / Open-Meteo
|
||||||
|
── /api/weather ──▶ in parallel:
|
||||||
|
providers/<name> (required) ──────────▶ chosen provider
|
||||||
|
extras/air-quality (optional) ──────────▶ Open-Meteo AQ
|
||||||
|
extras/nws-alerts (optional) ──────────▶ api.weather.gov
|
||||||
|
RadarPanel ── /api/radar ──▶ RainViewer frame index ─────────────────────▶ RainViewer
|
||||||
|
── /api/tiles/… ──▶ OpenWeather + CARTO tile proxy ─────────────▶ OpenWeather / CARTO CDN
|
||||||
|
── NEXRAD + RainViewer tiles load straight from their servers (no keys involved)
|
||||||
|
```
|
||||||
|
|
||||||
|
If an optional source fails, its section just disappears; only a failed forecast shows an error.
|
||||||
|
|
||||||
|
- `lib/weather/types.ts`: the one normalized shape. Units are metric, times are UTC, and missing readings are `null`.
|
||||||
|
- `lib/weather/providers/`: one adapter per provider. OpenWeather has a folder with `free.ts` and `onecall.ts`.
|
||||||
|
- `lib/weather/extras/`: air quality and NWS alerts.
|
||||||
|
- `lib/radar/layers.ts`: every map layer and how to load its frames.
|
||||||
|
- `lib/display.ts`: unit conversion, time-zone-aware formatting, UV/AQI labels, moon phase, precipitation outlook.
|
||||||
|
- `components/forecast/`: `ForecastPanel` (the switch), `HourlyView` + `HourlyChart`, `DailyView`.
|
||||||
|
- `components/radar/`: `RadarPanel` (controls) and `RadarMap` (Leaflet, browser-only).
|
||||||
|
- `components/ui/ChoiceChips.tsx`: the chip group used for providers, views, ranges and map layers.
|
||||||
|
|
||||||
|
### Adding a provider
|
||||||
|
|
||||||
|
1. Write `lib/weather/providers/foo.ts` exporting a `WeatherProvider`, with `getForecast()` and `limits()`.
|
||||||
|
2. Add `"foo"` to `ProviderId` in `types.ts`.
|
||||||
|
3. Register it in `providers/index.ts`, then add its key to `.env.example`.
|
||||||
|
|
||||||
|
## Styling (Tailwind v4)
|
||||||
|
|
||||||
|
There is no `tailwind.config.js`; v4 is configured in CSS.
|
||||||
|
|
||||||
|
- `app/globals.css` holds the theme tokens in `@theme`. Colors like `--color-gold` become `bg-gold`, `text-gold` and so on, and the fonts become `font-display`, `font-pixel` and `font-sans`. It also holds one custom utility, `chamfer` (the cut corners), Leaflet control overrides, and a few base rules.
|
||||||
|
- Every other style is a utility class in the component that uses it. Layout is mobile-first, with `sm:` (640px and up) for the desktop arrangement.
|
||||||
|
- `lib/styles.ts` holds the class strings used by more than one component.
|
||||||
|
- To resize a frame's corners, add `[--cut:22px]` to that `Slot`'s `className`. Put fixed heights on the `Slot` itself, not its inner element, which is already `h-full`.
|
||||||
|
- The Leaflet overrides sit outside any `@layer` and are prefixed with `.leaflet-container`. Leaflet's own CSS is unlayered and loads later, so anything weaker loses.
|
||||||
|
- The hourly chart is hand-written SVG with no chart library. Temperature and chance of precipitation sit in separate bands on one time axis rather than sharing two y-axes.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
- Uses `viewport-fit=cover` with safe-area insets, 44px tap targets and 16px inputs (so iOS doesn't zoom on focus).
|
||||||
|
- Chips, the hourly chart and the forecast strip scroll sideways; the page itself never does.
|
||||||
|
- The map ignores the scroll wheel, so scrolling the page doesn't zoom it; use pinch or the +/− buttons.
|
||||||
|
- "Use my location" requires HTTPS on phones. To test on your Android over LAN, use a Vercel preview deploy.
|
||||||
|
- Radar autoplay and the floating icon stop when the OS asks for reduced motion.
|
||||||
|
|
||||||
|
## Deploy & Legal Compliance
|
||||||
|
|
||||||
|
- **Vercel**: import the repo and add your environment variables (`OPENWEATHER_API_KEY`, `CARTO_API_KEY`, `NWS_CONTACT`, plus any optional provider keys) in Project Settings.
|
||||||
|
- **PM2**: `npm run build && pm2 start npm --name wayfarer -- start`.
|
||||||
|
- **CARTO domain lock**: once deployed, restrict `CARTO_API_KEY` to your production domain at `dashboard.basemaps.carto.com`. All `/api/*` routes also reject cross-site `Sec-Fetch-Site: cross-site` hotlinking automatically.
|
||||||
|
- **Built-in attributions**: `SiteFooter`, `PlaceSearch`, `AlertsBanner`, and `RadarMap` render the required live backlinks and license credits for OpenWeather (`CC BY-SA 4.0` / `ODbL`), Open-Meteo (`CC BY 4.0`), Copernicus Atmosphere Monitoring Service (`CAMS`), GeoNames (`CC BY 4.0`), WeatherAPI.com, Tomorrow.io, OpenStreetMap, CARTO, RainViewer, Iowa State IEM, and NOAA/NWS.
|
||||||
|
- **Non-commercial vs. commercial publishing**:
|
||||||
|
- **Free, ad-free personal / portfolio site**: compliant across every source out of the box.
|
||||||
|
- **Commercial site (ads, paid tiers, or business use)**: OpenWeather, WeatherAPI.com, NWS, and CARTO (`CARTO_API_KEY` includes 1M commercial tiles/month) permit commercial use within plan quotas, whereas **Open-Meteo** (forecast, geocoding, air quality), **RainViewer**, and **Tomorrow.io** free tiers are **non-commercial only** and require paid commercial plans or disabling before monetizing.
|
||||||
28
app/api/geocode/route.ts
Normal file
28
app/api/geocode/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { reversePlace, searchPlaces } from "@/lib/weather/geocode";
|
||||||
|
import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/geocode?q=Burnsville → Place[] (search by name)
|
||||||
|
* GET /api/geocode?lat=44.7&lon=-93.2 → Place (name for coordinates)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const blocked = forbidCrossSite(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
|
||||||
|
const params = request.nextUrl.searchParams;
|
||||||
|
const query = params.get("q")?.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (query) {
|
||||||
|
if (query.length < 2) return NextResponse.json([]);
|
||||||
|
return NextResponse.json(await searchPlaces(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = parseCoords(params);
|
||||||
|
if (!coords) return badRequest("Pass either q, or lat and lon");
|
||||||
|
return NextResponse.json(await reversePlace(coords.lat, coords.lon));
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/api/providers/route.ts
Normal file
10
app/api/providers/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { listProviders } from "@/lib/weather/providers";
|
||||||
|
|
||||||
|
// Read env vars per request so adding a key doesn't require a rebuild.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** GET /api/providers → which providers exist and which have keys configured. */
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json(listProviders());
|
||||||
|
}
|
||||||
43
app/api/radar/route.ts
Normal file
43
app/api/radar/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { fetchJson } from "@/lib/weather/http";
|
||||||
|
import { errorResponse, forbidCrossSite } from "@/lib/weather/respond";
|
||||||
|
import type { RadarFrame } from "@/lib/weather/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/radar → RainViewer frames, oldest first, ready for Leaflet.
|
||||||
|
*
|
||||||
|
* RainViewer publishes an index of frames. Each frame's `path` is a hash, not a
|
||||||
|
* timestamp, so tile URLs must always be built from this index — never guessed.
|
||||||
|
* Docs: https://www.rainviewer.com/api/weather-maps-api.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RainViewerIndex {
|
||||||
|
host: string;
|
||||||
|
radar: { past: { time: number; path: string }[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last 7 frames ≈ one hour. Keeps tile requests well under RainViewer's
|
||||||
|
// limit of 100 requests per minute per IP.
|
||||||
|
const FRAME_COUNT = 7;
|
||||||
|
const COLOR_SCHEME = 2; // "Universal Blue", the only scheme still offered
|
||||||
|
const OPTIONS = "1_1"; // smoothed, snow shown in its own colors
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const blocked = forbidCrossSite(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const index = await fetchJson<RainViewerIndex>("https://api.rainviewer.com/public/weather-maps.json", "RainViewer", {
|
||||||
|
cacheSeconds: 300, // new frames arrive every 10 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames: RadarFrame[] = index.radar.past.slice(-FRAME_COUNT).map((frame) => ({
|
||||||
|
time: new Date(frame.time * 1000).toISOString(),
|
||||||
|
url: `${index.host}${frame.path}/256/{z}/{x}/{y}/${COLOR_SCHEME}/${OPTIONS}.png`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(frames);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/api/tiles/[layer]/[z]/[x]/[y]/route.ts
Normal file
92
app/api/tiles/[layer]/[z]/[x]/[y]/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { OWM_TILE_LAYERS } from "@/lib/radar/layers";
|
||||||
|
import { forbidCrossSite } from "@/lib/weather/respond";
|
||||||
|
|
||||||
|
const CARTO_LAYERS: Record<string, string> = {
|
||||||
|
"carto-dark-nolabels": "dark_nolabels",
|
||||||
|
"carto-dark-labels": "dark_only_labels",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARTO_SUBDOMAINS = ["a", "b", "c", "d"] as const;
|
||||||
|
const MAX_ZOOM = 18;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tiles/[layer]/[z]/[x]/[y] → one map tile (PNG).
|
||||||
|
*
|
||||||
|
* Proxies both OpenWeather weather overlays (OPENWEATHER_API_KEY) and CARTO
|
||||||
|
* dark basemap/label tiles (CARTO_API_KEY) server-side so keys stay in `.env`
|
||||||
|
* or `.env.local` and cannot be hotlinked from other domains.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ layer: string; z: string; x: string; y: string }> },
|
||||||
|
) {
|
||||||
|
const blocked = forbidCrossSite(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
|
||||||
|
const { layer, z, x, y } = await context.params;
|
||||||
|
const [zi, xi, yi] = [z, x, y].map(Number);
|
||||||
|
const maxIndex = 2 ** zi;
|
||||||
|
|
||||||
|
if (
|
||||||
|
![zi, xi, yi].every((n) => Number.isInteger(n) && n >= 0) ||
|
||||||
|
zi > MAX_ZOOM ||
|
||||||
|
xi >= maxIndex ||
|
||||||
|
yi >= maxIndex
|
||||||
|
) {
|
||||||
|
return new Response("Bad tile coordinates", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. CARTO dark basemap & place-label tiles
|
||||||
|
const cartoStyle = CARTO_LAYERS[layer];
|
||||||
|
if (cartoStyle) {
|
||||||
|
const key = (process.env.CARTO_API_KEY ?? process.env.NEXT_PUBLIC_CARTO_API_KEY ?? "").trim();
|
||||||
|
const scale = request.nextUrl.searchParams.get("r") === "@2x" ? "@2x" : "";
|
||||||
|
const sub = CARTO_SUBDOMAINS[(xi + yi) % CARTO_SUBDOMAINS.length];
|
||||||
|
const query = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||||
|
const url = `https://${sub}.basemaps.cartocdn.com/${cartoStyle}/${z}/${x}/${y}${scale}.png${query}`;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
const referer = request.headers.get("referer");
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
if (referer) headers.Referer = referer;
|
||||||
|
if (origin) headers.Origin = origin;
|
||||||
|
|
||||||
|
const upstream = await fetch(
|
||||||
|
url,
|
||||||
|
key ? { headers, next: { revalidate: 86_400 } } : { headers, cache: "no-store" },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
|
||||||
|
|
||||||
|
// CARTO tags unauthenticated placeholder tiles with an `"wm-..."` ETag; never cache those.
|
||||||
|
const isWatermarked = !key || Boolean(upstream.headers.get("etag")?.includes("wm-"));
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Cache-Control": isWatermarked ? "no-store" : "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. OpenWeather weather-model overlay tiles
|
||||||
|
const owmLayer = OWM_TILE_LAYERS[layer];
|
||||||
|
const owmKey = process.env.OPENWEATHER_API_KEY?.trim();
|
||||||
|
|
||||||
|
if (!owmLayer) return new Response("Unknown layer", { status: 404 });
|
||||||
|
if (!owmKey) return new Response("OPENWEATHER_API_KEY is not set", { status: 503 });
|
||||||
|
|
||||||
|
const upstream = await fetch(`https://tile.openweathermap.org/map/${owmLayer}/${z}/${x}/${y}.png?appid=${owmKey}`, {
|
||||||
|
next: { revalidate: 600 },
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Cache-Control": "public, max-age=600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
57
app/api/weather/route.ts
Normal file
57
app/api/weather/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { getLocalContext, type LocalContext } from "@/lib/weather/extras/air-quality";
|
||||||
|
import { getNwsAlerts } from "@/lib/weather/extras/nws-alerts";
|
||||||
|
import { isProviderId, isReady, PROVIDERS } from "@/lib/weather/providers";
|
||||||
|
import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond";
|
||||||
|
import type { WeatherAlert, WeatherReport } from "@/lib/weather/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/weather?provider=openweather&lat=44.77&lon=-93.27
|
||||||
|
*
|
||||||
|
* Runs on the server so API keys stay in .env / .env.local and never reach the browser.
|
||||||
|
*
|
||||||
|
* Three things are fetched at once:
|
||||||
|
* 1. the forecast from the chosen provider (required — failure is an error)
|
||||||
|
* 2. air quality + time zone from Open-Meteo (optional — failure just hides it)
|
||||||
|
* 3. NWS alerts for US locations (optional — failure just hides them)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const blocked = forbidCrossSite(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
|
||||||
|
const params = request.nextUrl.searchParams;
|
||||||
|
const providerId = params.get("provider") ?? "openweather";
|
||||||
|
const coords = parseCoords(params);
|
||||||
|
|
||||||
|
if (!isProviderId(providerId)) return badRequest(`Unknown provider "${providerId}"`);
|
||||||
|
if (!coords) return badRequest("lat and lon are required");
|
||||||
|
|
||||||
|
const provider = PROVIDERS[providerId];
|
||||||
|
if (!isReady(provider)) return badRequest(`${provider.label} has no API key configured`);
|
||||||
|
|
||||||
|
const [forecast, context, nwsAlerts] = await Promise.allSettled([
|
||||||
|
provider.getForecast(coords.lat, coords.lon),
|
||||||
|
getLocalContext(coords.lat, coords.lon),
|
||||||
|
getNwsAlerts(coords.lat, coords.lon),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (forecast.status === "rejected") return errorResponse(forecast.reason);
|
||||||
|
|
||||||
|
const local: LocalContext | null = context.status === "fulfilled" ? context.value : null;
|
||||||
|
const official: WeatherAlert[] = nwsAlerts.status === "fulfilled" ? nwsAlerts.value : [];
|
||||||
|
|
||||||
|
// Prefer official NWS alerts in the US; elsewhere use whatever the provider gave us.
|
||||||
|
const alerts = official.length > 0 ? official : forecast.value.alerts;
|
||||||
|
const alertSource = official.length > 0 ? "National Weather Service" : alerts.length > 0 ? provider.label : null;
|
||||||
|
|
||||||
|
const report: WeatherReport = {
|
||||||
|
...forecast.value,
|
||||||
|
provider: providerId,
|
||||||
|
timeZone: forecast.value.timeZone ?? local?.timeZone ?? null,
|
||||||
|
airQuality: local?.airQuality ?? null,
|
||||||
|
alerts,
|
||||||
|
alertSource,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return NextResponse.json(report);
|
||||||
|
}
|
||||||
96
app/globals.css
Normal file
96
app/globals.css
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Wayfarer theme — Tailwind v4.
|
||||||
|
Tokens here become utilities: bg-gold, text-stone, font-pixel…
|
||||||
|
All styling lives as utility classes in the components.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* `static` keeps every color as a CSS variable even if no class uses it,
|
||||||
|
because the pixel sprites reference them directly (lib/pixel-art.ts). */
|
||||||
|
@theme static {
|
||||||
|
--color-black: #000000;
|
||||||
|
--color-panel: #0a0905; /* faint warmth inside frames so gold reads as metal */
|
||||||
|
--color-gold: #c9a54c;
|
||||||
|
--color-gold-dim: #5c4a1f;
|
||||||
|
--color-gold-bright: #f2d98b;
|
||||||
|
--color-ink: #e8e0c9;
|
||||||
|
--color-stone: #857e6c;
|
||||||
|
--color-teal: #58cbbd;
|
||||||
|
--color-teal-off: #1d2a28;
|
||||||
|
--color-heart: #d64a3c;
|
||||||
|
--color-heart-empty: #4a2420;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-display: "Cinzel", Georgia, serif;
|
||||||
|
--font-pixel: "Silkscreen", system-ui, sans-serif;
|
||||||
|
--font-sans: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* The one ambient motion: the hero sprite drifts like a held item. */
|
||||||
|
--animate-float: float 3.2s ease-in-out infinite;
|
||||||
|
@keyframes float {
|
||||||
|
50% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cut-corner shape for every frame. Set the corner size per element
|
||||||
|
with [--cut:22px]; it defaults to 14px. */
|
||||||
|
@utility chamfer {
|
||||||
|
clip-path: polygon(
|
||||||
|
var(--cut, 14px) 0, calc(100% - var(--cut, 14px)) 0, 100% var(--cut, 14px),
|
||||||
|
100% calc(100% - var(--cut, 14px)), calc(100% - var(--cut, 14px)) 100%,
|
||||||
|
var(--cut, 14px) 100%, 0 calc(100% - var(--cut, 14px)), 0 var(--cut, 14px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet builds its own controls, so they can't take utility classes.
|
||||||
|
Two things make these overrides win over leaflet.css:
|
||||||
|
- they are NOT in an @layer (unlayered CSS always beats layered CSS), and
|
||||||
|
- each starts with .leaflet-container, so it is more specific than Leaflet's
|
||||||
|
own rule even though leaflet.css loads later. */
|
||||||
|
.leaflet-container {
|
||||||
|
@apply bg-black font-sans;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-bar {
|
||||||
|
@apply rounded-none border border-gold-dim shadow-none;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-bar a {
|
||||||
|
@apply rounded-none border-gold-dim bg-black text-gold-bright;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-bar a:hover,
|
||||||
|
.leaflet-container .leaflet-bar a:focus-visible {
|
||||||
|
@apply bg-gold text-black;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-control.leaflet-control-attribution {
|
||||||
|
@apply bg-black/70 text-[10px] text-stone;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-control-attribution a {
|
||||||
|
@apply text-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply min-h-dvh bg-black font-sans text-ink antialiased;
|
||||||
|
/* A single low ember of light behind the hero; the rest stays pure black. */
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 65% 42% at 50% 10%, rgb(201 165 76 / 0.11), transparent 72%),
|
||||||
|
radial-gradient(ellipse 40% 30% at 80% 24%, rgb(88 203 189 / 0.04), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the browser's unstyled native clear button on search inputs; PlaceSearch renders its own. */
|
||||||
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
@apply outline-2 outline-offset-3 outline-teal;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/layout.tsx
Normal file
32
app/layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
|
||||||
|
// Fonts are bundled from npm, so builds never depend on reaching Google Fonts.
|
||||||
|
import "@fontsource/cinzel/500.css";
|
||||||
|
import "@fontsource/cinzel/700.css";
|
||||||
|
import "@fontsource/silkscreen/400.css";
|
||||||
|
import "@fontsource/ibm-plex-sans/400.css";
|
||||||
|
import "@fontsource/ibm-plex-sans/500.css";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Wayfarer",
|
||||||
|
description: "Weather for the road ahead — by Cayden Codes.",
|
||||||
|
authors: [{ name: "Cayden Codes", url: "https://caydens.dev" }],
|
||||||
|
creator: "Cayden Codes",
|
||||||
|
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "Wayfarer" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover", // lets the layout use the full screen on notched phones
|
||||||
|
themeColor: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import WeatherApp from "@/components/WeatherApp";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <WeatherApp />;
|
||||||
|
}
|
||||||
49
components/AlertsBanner.tsx
Normal file
49
components/AlertsBanner.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { formatClock, formatShortDate } from "@/lib/display";
|
||||||
|
import { pixelLabel } from "@/lib/styles";
|
||||||
|
import type { AlertSeverity, WeatherAlert } from "@/lib/weather/types";
|
||||||
|
import { Slot } from "./Slot";
|
||||||
|
|
||||||
|
const SEVERITY_TEXT: Record<AlertSeverity, string> = {
|
||||||
|
extreme: "text-heart",
|
||||||
|
severe: "text-heart",
|
||||||
|
moderate: "text-gold-bright",
|
||||||
|
minor: "text-ink",
|
||||||
|
unknown: "text-ink",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Active weather alerts. Each one opens to show the full text and instructions. */
|
||||||
|
export function AlertsBanner({ alerts, source, timeZone }: { alerts: WeatherAlert[]; source: string | null; timeZone: string | null }) {
|
||||||
|
if (alerts.length === 0) return null;
|
||||||
|
|
||||||
|
const when = (iso: string | null) => (iso ? `${formatShortDate(iso, timeZone)}, ${formatClock(iso, timeZone)}` : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot as="section" tone="danger" innerClassName="flex flex-col gap-2 p-4 sm:px-5">
|
||||||
|
<h2 className="font-display font-bold text-heart">
|
||||||
|
{alerts.length === 1 ? "1 active alert" : `${alerts.length} active alerts`}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{alerts.map((alert, i) => (
|
||||||
|
<details key={`${alert.title}-${alert.starts ?? i}`} className="group border-t border-heart-empty pt-2">
|
||||||
|
<summary className={`cursor-pointer list-none font-medium ${SEVERITY_TEXT[alert.severity]}`}>
|
||||||
|
<span className="mr-2 inline-block transition-transform group-open:rotate-90" aria-hidden="true">›</span>
|
||||||
|
{alert.title}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 flex flex-col gap-2 pl-4 text-sm">
|
||||||
|
<p className={pixelLabel}>
|
||||||
|
{[alert.source, when(alert.starts) && `from ${when(alert.starts)}`, when(alert.ends) && `until ${when(alert.ends)}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-line">{alert.description}</p>
|
||||||
|
{alert.instruction && <p className="whitespace-pre-line text-gold-bright">{alert.instruction}</p>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<p className={pixelLabel}>
|
||||||
|
{source ? `Alerts from ${source} · ` : ""}Follow official local emergency guidance for life-safety decisions.
|
||||||
|
</p>
|
||||||
|
</Slot>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
components/ConditionsGrid.tsx
Normal file
128
components/ConditionsGrid.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
aqiLabel, compass, formatClock, formatDistance, formatDuration, formatPrecip, formatPressure, formatTemp,
|
||||||
|
formatWind, moonPhase, uvLabel, type Units,
|
||||||
|
} from "@/lib/display";
|
||||||
|
import { pixelLabel, sectionHeading } from "@/lib/styles";
|
||||||
|
import type { WeatherReport } from "@/lib/weather/types";
|
||||||
|
import { Slot } from "./Slot";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every current reading the provider gives us, one tile each.
|
||||||
|
* Tiles for readings the provider doesn't offer are simply left out.
|
||||||
|
*/
|
||||||
|
export function ConditionsGrid({ report, units }: { report: WeatherReport; units: Units }) {
|
||||||
|
const { current: c, airQuality: aq, timeZone } = report;
|
||||||
|
const today = report.daily[0];
|
||||||
|
const moon = moonPhase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="conditions-heading" className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 id="conditions-heading" className={sectionHeading}>
|
||||||
|
Current conditions
|
||||||
|
</h2>
|
||||||
|
<span className="h-px flex-1 bg-linear-to-r from-gold-dim/70 to-transparent" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Tile label="Feels like" value={formatTemp(c.feelsLikeC, units)} note={c.dewPointC !== null ? `Dew point ${formatTemp(c.dewPointC, units)}` : undefined} />
|
||||||
|
|
||||||
|
<Tile label="Humidity" value={`${c.humidity}%`} meter={<Segments total={10} lit={Math.round(c.humidity / 10)} />} />
|
||||||
|
|
||||||
|
<Tile
|
||||||
|
label="Wind"
|
||||||
|
value={formatWind(c.windKph, units)}
|
||||||
|
note={[c.windDeg !== null && `From ${compass(c.windDeg)}`, c.gustKph && `gusts ${formatWind(c.gustKph, units)}`].filter(Boolean).join(", ") || undefined}
|
||||||
|
icon={c.windDeg !== null ? <WindArrow fromDegrees={c.windDeg} /> : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{c.uvIndex !== null && (
|
||||||
|
<Tile
|
||||||
|
label="UV index"
|
||||||
|
value={String(Math.round(c.uvIndex))}
|
||||||
|
note={uvLabel(c.uvIndex)}
|
||||||
|
meter={<Segments total={11} lit={Math.min(11, Math.round(c.uvIndex))} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aq && (
|
||||||
|
<Tile label="Air quality" value={`${aq.usAqi} AQI`} note={`${aqiLabel(aq.usAqi)}${aq.pm25 !== null ? ` · PM2.5 ${Math.round(aq.pm25)} µg/m³` : ""}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{c.pressureHpa !== null && <Tile label="Pressure" value={formatPressure(c.pressureHpa, units)} />}
|
||||||
|
{c.visibilityKm !== null && <Tile label="Visibility" value={formatDistance(c.visibilityKm, units)} />}
|
||||||
|
{c.cloudCover !== null && <Tile label="Cloud cover" value={`${Math.round(c.cloudCover)}%`} />}
|
||||||
|
{c.precipMm !== null && <Tile label="Precipitation" value={formatPrecip(c.precipMm, units)} note="In the last hour" />}
|
||||||
|
|
||||||
|
{today?.sunrise && today.sunset && (
|
||||||
|
<Tile
|
||||||
|
label="Sun"
|
||||||
|
value={`${formatClock(today.sunrise, timeZone)} – ${formatClock(today.sunset, timeZone)}`}
|
||||||
|
note={`${formatDuration(today.sunrise, today.sunset)} of daylight`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tile label="Moon" value={moon.name} note={`${moon.illumination}% lit`} icon={<MoonIcon fraction={moon.fraction} />} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One reading. `icon` sits beside the value; `meter` sits underneath it.
|
||||||
|
*/
|
||||||
|
function Tile({ label, value, note, icon, meter }: { label: string; value: string; note?: string; icon?: ReactNode; meter?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Slot className="[--cut:10px]" innerClassName="flex flex-col gap-1 p-3 sm:px-4">
|
||||||
|
<p className={pixelLabel}>{label}</p>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-display text-lg font-medium sm:text-xl">{value}</p>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{note && <p className="text-xs text-stone first-letter:uppercase">{note}</p>}
|
||||||
|
{meter}
|
||||||
|
</Slot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of lit/unlit segments, like a stamina gauge. */
|
||||||
|
function Segments({ total, lit }: { total: number; lit: number }) {
|
||||||
|
return (
|
||||||
|
<span className="mt-1 flex gap-[3px]" aria-hidden="true">
|
||||||
|
{Array.from({ length: total }, (_, i) => (
|
||||||
|
<span key={i} className={`h-1.5 flex-1 ${i < lit ? "bg-teal" : "bg-teal-off"}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An arrow pointing where the wind is blowing TO (opposite of where it comes from). */
|
||||||
|
function WindArrow({ fromDegrees }: { fromDegrees: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={28} height={28} viewBox="-10 -10 20 20" aria-hidden="true" style={{ transform: `rotate(${fromDegrees + 180}deg)` }}>
|
||||||
|
<path d="M0,-8 L5,4 L0,1 L-5,4 Z" fill="var(--color-gold-bright)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moon drawn from its phase: a dark disc, with the lit part outlined by
|
||||||
|
* an outer half-circle and a terminator ellipse whose width follows the phase.
|
||||||
|
*/
|
||||||
|
function MoonIcon({ fraction }: { fraction: number }) {
|
||||||
|
const r = 9;
|
||||||
|
const waxing = fraction < 0.5;
|
||||||
|
const terminatorWidth = r * Math.abs(Math.cos(fraction * 2 * Math.PI));
|
||||||
|
// The terminator bulges toward the lit side for crescents and away from it for gibbous moons.
|
||||||
|
const isCrescent = fraction < 0.25 || fraction > 0.75;
|
||||||
|
const outerSweep = waxing ? 1 : 0;
|
||||||
|
const innerSweep = isCrescent === waxing ? 0 : 1;
|
||||||
|
const lit = `M0,${-r} A${r},${r} 0 0 ${outerSweep} 0,${r} A${terminatorWidth},${r} 0 0 ${innerSweep} 0,${-r} Z`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={28} height={28} viewBox="-10 -10 20 20" aria-hidden="true">
|
||||||
|
<circle r={r} fill="var(--color-teal-off)" />
|
||||||
|
<path d={lit} fill="var(--color-ink)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
components/HomeLanding.tsx
Normal file
177
components/HomeLanding.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { formatCoords } from "@/lib/display";
|
||||||
|
import { outlineButton, pixelLabel, sectionHeading } from "@/lib/styles";
|
||||||
|
import type { Place } from "@/lib/weather/types";
|
||||||
|
import { HeartMeter, SkyIcon } from "./PixelArt";
|
||||||
|
import { Slot } from "./Slot";
|
||||||
|
|
||||||
|
const WAYPOINTS: { place: Place; terrain: string }[] = [
|
||||||
|
{
|
||||||
|
place: { name: "New York", region: "New York", country: "US", lat: 40.7128, lon: -74.006 },
|
||||||
|
terrain: "Atlantic harbor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
place: { name: "Seattle", region: "Washington", country: "US", lat: 47.6062, lon: -122.3321 },
|
||||||
|
terrain: "Puget Sound",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
place: { name: "Denver", region: "Colorado", country: "US", lat: 39.7392, lon: -104.9903 },
|
||||||
|
terrain: "Front Range",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
place: { name: "Reykjavik", country: "IS", lat: 64.1466, lon: -21.9426 },
|
||||||
|
terrain: "North Atlantic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
place: { name: "London", country: "GB", lat: 51.5072, lon: -0.1276 },
|
||||||
|
terrain: "Thames valley",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
place: { name: "Tokyo", country: "JP", lat: 35.6762, lon: 139.6503 },
|
||||||
|
terrain: "Tokyo Bay",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HIGHLIGHTS = [
|
||||||
|
{
|
||||||
|
label: "Horizons",
|
||||||
|
title: "Hourly & 14-day",
|
||||||
|
detail: "Crosshair temperature & precip chart, exact hourly table, and daily range bars.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Live sky map",
|
||||||
|
title: "Animated radar",
|
||||||
|
detail: "High-detail US NEXRAD & global RainViewer loops, plus 5 OpenWeather tile layers.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Field readings",
|
||||||
|
title: "Comfort & AQI",
|
||||||
|
detail: "Five-heart comfort gauge, US AQI & PM2.5, UV stamina bars, NWS alerts, and moon phase.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Homepage landing view shown before a destination is chosen.
|
||||||
|
* Introduces the station aesthetic and lets visitors jump straight into a waypoint.
|
||||||
|
*/
|
||||||
|
export function HomeLanding({
|
||||||
|
onSelectPlace,
|
||||||
|
onFocusSearch,
|
||||||
|
activeProviderLabel,
|
||||||
|
}: {
|
||||||
|
onSelectPlace: (place: Place) => void;
|
||||||
|
onFocusSearch: () => void;
|
||||||
|
activeProviderLabel?: string;
|
||||||
|
}) {
|
||||||
|
const [isDaytime, setIsDaytime] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
setIsDaytime(hour >= 6 && hour < 19);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Hero welcome frame */}
|
||||||
|
<Slot
|
||||||
|
as="section"
|
||||||
|
className="[--cut:22px]"
|
||||||
|
innerClassName="grid items-center gap-6 p-6 sm:grid-cols-[auto_1fr] sm:gap-8 sm:px-8 sm:py-8"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3 justify-self-center sm:justify-self-start">
|
||||||
|
<div className="relative grid aspect-square w-[148px] place-items-center border border-gold-dim bg-black bg-radial-[circle_at_50%_45%] from-gold-bright/14 to-transparent to-70% sm:w-[168px]">
|
||||||
|
<SkyIcon
|
||||||
|
condition="clear"
|
||||||
|
isDay={isDaytime}
|
||||||
|
size={112}
|
||||||
|
className="h-auto w-[96px] animate-float motion-reduce:animate-none sm:w-[112px]"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 bottom-2 flex gap-1.5 border border-gold-dim/70 bg-panel/90 px-1.5 py-1">
|
||||||
|
<SkyIcon condition="clouds" size={16} />
|
||||||
|
<SkyIcon condition="rain" size={16} />
|
||||||
|
<SkyIcon condition="storm" size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HeartMeter hearts={4.5} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="font-pixel text-[0.7rem] tracking-[0.06em] text-teal">
|
||||||
|
{activeProviderLabel ? `${activeProviderLabel} active · Ready` : "Weather station ready"}
|
||||||
|
</p>
|
||||||
|
<h2 className="font-display text-2xl leading-tight font-medium tracking-[0.02em] text-gold-bright sm:text-3xl">
|
||||||
|
Read the skies before you set out.
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-[54ch] text-sm leading-relaxed text-stone sm:text-base">
|
||||||
|
Search any town or city above, share your current coordinates, or choose a waypoint below to inspect live conditions, hourly horizons, and animated radar.
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" onClick={onFocusSearch} className={outlineButton}>
|
||||||
|
Search a destination
|
||||||
|
</button>
|
||||||
|
<span className={pixelLabel}>Or pick a waypoint ↓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Slot>
|
||||||
|
|
||||||
|
{/* Quick-start waypoints */}
|
||||||
|
<section aria-labelledby="waypoints-heading" className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 id="waypoints-heading" className={sectionHeading}>
|
||||||
|
Suggested waypoints
|
||||||
|
</h2>
|
||||||
|
<span className="h-px flex-1 bg-linear-to-r from-gold-dim/70 to-transparent" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||||
|
{WAYPOINTS.map(({ place, terrain }) => (
|
||||||
|
<Slot key={place.name} className="[--cut:10px] transition-transform duration-150 hover:-translate-y-0.5" innerClassName="p-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectPlace(place)}
|
||||||
|
className="group flex h-full w-full cursor-pointer flex-col justify-between gap-2 p-3.5 text-left transition-colors hover:bg-gold/8 focus-visible:outline-offset-[-2px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-display text-base font-medium text-ink transition-colors group-hover:text-gold-bright">
|
||||||
|
{place.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone">
|
||||||
|
{place.region ? `${place.region}, ${place.country}` : place.country} · {terrain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="font-pixel text-xs text-gold-dim transition-transform group-hover:translate-x-0.5 group-hover:text-gold-bright"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-pixel text-[0.65rem] text-teal/85 tabular-nums">
|
||||||
|
{formatCoords(place.lat, place.lon, 1)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</Slot>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Station overview */}
|
||||||
|
<section aria-label="What Wayfarer includes" className="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||||
|
{HIGHLIGHTS.map((item) => (
|
||||||
|
<Slot key={item.title} className="[--cut:10px]" innerClassName="flex flex-col gap-1 p-3.5">
|
||||||
|
<p className={pixelLabel}>{item.label}</p>
|
||||||
|
<p className="font-display text-base font-medium text-gold-bright">{item.title}</p>
|
||||||
|
<p className="text-xs leading-relaxed text-stone">{item.detail}</p>
|
||||||
|
</Slot>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/PixelArt.tsx
Normal file
73
components/PixelArt.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { CLOUD, FOG, HEART_EMPTY, HEART_FULL, HEART_HALF, MOON, PALETTE, RAIN, SNOW, STORM, SUN } from "@/lib/pixel-art";
|
||||||
|
import type { Condition } from "@/lib/weather/types";
|
||||||
|
|
||||||
|
interface PixelArtProps {
|
||||||
|
rows: string[];
|
||||||
|
size: number; // default rendered width in px (a className can override it)
|
||||||
|
label?: string; // omit for decorative art
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a text-drawn sprite (see lib/pixel-art.ts) as crisp SVG squares. */
|
||||||
|
export function PixelArt({ rows, size, label, className }: PixelArtProps) {
|
||||||
|
const width = rows[0].length;
|
||||||
|
const height = rows.length;
|
||||||
|
|
||||||
|
const pixels = rows.flatMap((row, y) =>
|
||||||
|
[...row].map((char, x) =>
|
||||||
|
char === "." ? null : <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={PALETTE[char]} />,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
height={(size / width) * height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
role={label ? "img" : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={label ? undefined : true}
|
||||||
|
>
|
||||||
|
{pixels}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKY_ART: Record<Condition, string[]> = {
|
||||||
|
clear: SUN,
|
||||||
|
clouds: CLOUD,
|
||||||
|
rain: RAIN,
|
||||||
|
storm: STORM,
|
||||||
|
snow: SNOW,
|
||||||
|
fog: FOG,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Picks the sprite for a condition; clear nights get the moon. */
|
||||||
|
export function SkyIcon({ condition, isDay = true, size, className }: {
|
||||||
|
condition: Condition;
|
||||||
|
isDay?: boolean;
|
||||||
|
size: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const rows = condition === "clear" && !isDay ? MOON : SKY_ART[condition];
|
||||||
|
return <PixelArt rows={rows} size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of five hearts showing a 0–5 score in half steps. */
|
||||||
|
export function HeartMeter({ hearts }: { hearts: number }) {
|
||||||
|
const sprites = [0, 1, 2, 3, 4].map((i) => {
|
||||||
|
if (hearts >= i + 1) return HEART_FULL;
|
||||||
|
if (hearts >= i + 0.5) return HEART_HALF;
|
||||||
|
return HEART_EMPTY;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-[5px]" role="img" aria-label={`Comfort: ${hearts} of 5 hearts`}>
|
||||||
|
{sprites.map((rows, i) => (
|
||||||
|
<PixelArt key={i} rows={rows} size={22} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
components/PlaceSearch.tsx
Normal file
271
components/PlaceSearch.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type RefObject, useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { namePlace, searchPlaces } from "@/lib/client-api";
|
||||||
|
import { formatCoords } from "@/lib/display";
|
||||||
|
import { outlineButton } from "@/lib/styles";
|
||||||
|
import type { Place } from "@/lib/weather/types";
|
||||||
|
|
||||||
|
/** City search with debounced suggestions, arrow-key navigation, and "use my location". */
|
||||||
|
export function PlaceSearch({
|
||||||
|
onSelect,
|
||||||
|
inputRef,
|
||||||
|
geocoderUsesOpenWeather = false,
|
||||||
|
}: {
|
||||||
|
onSelect: (place: Place) => void;
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
|
geocoderUsesOpenWeather?: boolean;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<Place[]>([]);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
|
const listId = useId();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close the dropdown when clicking outside the search box.
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOutsidePointer(event: PointerEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", handleOutsidePointer);
|
||||||
|
return () => document.removeEventListener("pointerdown", handleOutsidePointer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Wait until typing pauses for 350 ms, then search. Cancel stale requests.
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const places = await searchPlaces(trimmed, controller.signal);
|
||||||
|
setResults(places);
|
||||||
|
setActiveIndex(places.length > 0 ? 0 : -1);
|
||||||
|
setOpen(true);
|
||||||
|
setMessage(places.length ? null : `No places match “${trimmed}”. Try adding a state or country.`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) setMessage((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) setSearching(false);
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
function choose(place: Place) {
|
||||||
|
onSelect(place);
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
setOpen(false);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMyLocation() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setMessage("This browser can't share its location. Search for a city instead.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocating(true);
|
||||||
|
setMessage(null);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async ({ coords }) => {
|
||||||
|
try {
|
||||||
|
choose(await namePlace(coords.latitude, coords.longitude));
|
||||||
|
} catch (error) {
|
||||||
|
setMessage((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLocating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setLocating(false);
|
||||||
|
setMessage("Location access was blocked. Allow it in your browser, or search for a city.");
|
||||||
|
},
|
||||||
|
{ timeout: 10_000, maximumAge: 300_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDropdown = open && results.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="sr-only" htmlFor={`${listId}-input`}>
|
||||||
|
Search for a place
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative flex min-w-0 flex-1 items-center">
|
||||||
|
{/* Magnifying glass glyph */}
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-3.5 text-gold"
|
||||||
|
>
|
||||||
|
<circle cx="7" cy="7" r="4.75" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M10.7 10.7L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={`${listId}-input`}
|
||||||
|
type="search"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={showDropdown}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-activedescendant={showDropdown && activeIndex >= 0 ? `${listId}-opt-${activeIndex}` : undefined}
|
||||||
|
placeholder="Search a town, city, or region…"
|
||||||
|
autoComplete="off"
|
||||||
|
// text-base (16px) stops iOS from zooming in on focus
|
||||||
|
className="min-h-[46px] w-full min-w-0 rounded-none border border-gold-dim bg-panel/80 pr-20 pl-10 text-base text-ink transition-colors placeholder:text-stone hover:border-gold/70 focus-visible:border-teal focus-visible:outline-offset-0"
|
||||||
|
value={query}
|
||||||
|
onFocus={() => {
|
||||||
|
if (results.length > 0) setOpen(true);
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
if (!e.target.value.trim()) setMessage(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown" && results.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
setActiveIndex((prev) => (prev + 1) % results.length);
|
||||||
|
} else if (e.key === "ArrowUp" && results.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
setActiveIndex((prev) => (prev <= 0 ? results.length - 1 : prev - 1));
|
||||||
|
} else if (e.key === "Enter" && results.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(results[activeIndex >= 0 ? activeIndex : 0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute right-2 flex items-center gap-1.5">
|
||||||
|
{searching && <span className="font-pixel text-[0.65rem] text-gold">…</span>}
|
||||||
|
{query.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
setMessage(null);
|
||||||
|
inputRef?.current?.focus();
|
||||||
|
}}
|
||||||
|
className="grid size-7 cursor-pointer place-items-center font-pixel text-xs text-stone hover:text-gold-bright"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${outlineButton} inline-flex items-center gap-2`}
|
||||||
|
type="button"
|
||||||
|
onClick={useMyLocation}
|
||||||
|
disabled={locating}
|
||||||
|
>
|
||||||
|
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" aria-hidden="true" className="flex-none">
|
||||||
|
<circle cx="8" cy="8" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
|
||||||
|
<path d="M8 1V3M8 13V15M1 8H3M13 8H15" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">{locating ? "Finding you…" : "Use my location"}</span>
|
||||||
|
<span className="sm:hidden">{locating ? "Finding…" : "Locate"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-20 border border-gold bg-panel shadow-[0_18px_40px_rgba(0,0,0,0.85)]">
|
||||||
|
<ul id={listId} role="listbox" className="py-1">
|
||||||
|
{results.map((place, index) => {
|
||||||
|
const isActive = index === activeIndex;
|
||||||
|
return (
|
||||||
|
<li key={`${place.lat},${place.lon}`} id={`${listId}-opt-${index}`} role="option" aria-selected={isActive}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onPointerEnter={() => setActiveIndex(index)}
|
||||||
|
onClick={() => choose(place)}
|
||||||
|
className={`flex min-h-11 w-full cursor-pointer items-center justify-between gap-3 px-3.5 py-2 text-left transition-colors focus-visible:outline-none ${
|
||||||
|
isActive ? "bg-gold/15 text-gold-bright" : "text-ink hover:bg-gold/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-medium">{place.name}</span>
|
||||||
|
{(place.region || place.country) && (
|
||||||
|
<span className="text-stone">
|
||||||
|
{", "}
|
||||||
|
{[place.region, place.country].filter(Boolean).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-none font-pixel text-[0.65rem] text-stone tabular-nums">
|
||||||
|
{formatCoords(place.lat, place.lon, 1)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="border-t border-gold-dim/40 bg-black/60 px-3.5 py-1.5 text-[11px] text-stone">
|
||||||
|
{geocoderUsesOpenWeather ? (
|
||||||
|
<>
|
||||||
|
Location search by{" "}
|
||||||
|
<a href="https://openweathermap.org/" target="_blank" rel="noopener" className="text-gold hover:underline">
|
||||||
|
OpenWeather
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Location data by{" "}
|
||||||
|
<a href="https://open-meteo.com/" target="_blank" rel="noopener" className="text-gold hover:underline">
|
||||||
|
Open-Meteo.com
|
||||||
|
</a>{" "}
|
||||||
|
&{" "}
|
||||||
|
<a href="https://www.geonames.org/" target="_blank" rel="noopener" className="text-gold hover:underline">
|
||||||
|
GeoNames
|
||||||
|
</a>{" "}
|
||||||
|
(
|
||||||
|
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener" className="hover:underline">
|
||||||
|
CC BY 4.0
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && <p className="mt-2 text-sm text-stone">{message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
components/ProviderPicker.tsx
Normal file
114
components/ProviderPicker.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { pixelLabel } from "@/lib/styles";
|
||||||
|
import type { ProviderId, ProviderInfo } from "@/lib/weather/types";
|
||||||
|
import { Slot } from "./Slot";
|
||||||
|
import { ChoiceChips } from "./ui/ChoiceChips";
|
||||||
|
|
||||||
|
const PROVIDER_KEY_HINTS: Record<ProviderId, { envVar: string | null; summary: string }> = {
|
||||||
|
openweather: {
|
||||||
|
envVar: "OPENWEATHER_API_KEY",
|
||||||
|
summary: "5-day forecast, reverse location naming, and 5 extra map layers (precip, clouds, temp, wind, pressure).",
|
||||||
|
},
|
||||||
|
weatherapi: {
|
||||||
|
envVar: "WEATHERAPI_API_KEY",
|
||||||
|
summary: "3-day hourly & daily forecast plus worldwide weather alerts (instant key activation).",
|
||||||
|
},
|
||||||
|
tomorrow: {
|
||||||
|
envVar: "TOMORROW_API_KEY",
|
||||||
|
summary: "5-day hourly & 6-day daily timelines (free tier: 25 calls/hr, 500/day).",
|
||||||
|
},
|
||||||
|
openmeteo: {
|
||||||
|
envVar: null,
|
||||||
|
summary: "14-day daily & 7-day hourly forecast — free out of the box, no API key required.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider choices. Each shows how many forecast days it delivers on the
|
||||||
|
* configured plan. Providers without an API key stay visible with an optional
|
||||||
|
* setup guide so adding free keys to `.env.local` is self-explanatory.
|
||||||
|
*/
|
||||||
|
export function ProviderPicker({ providers, selected, onChange }: {
|
||||||
|
providers: ProviderInfo[];
|
||||||
|
selected: ProviderId;
|
||||||
|
onChange: (id: ProviderId) => void;
|
||||||
|
}) {
|
||||||
|
const [showKeyGuide, setShowKeyGuide] = useState(false);
|
||||||
|
const missingProviders = providers.filter((p) => !p.ready);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
|
<span className={pixelLabel}>Source</span>
|
||||||
|
<ChoiceChips
|
||||||
|
legend="Weather data source"
|
||||||
|
size="sm"
|
||||||
|
selected={selected}
|
||||||
|
onChange={onChange}
|
||||||
|
choices={providers.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.label,
|
||||||
|
disabled: !p.ready,
|
||||||
|
note: p.ready ? `${p.forecastDays}d` : "Locked",
|
||||||
|
noteTone: p.ready ? "muted" : "subtle",
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{missingProviders.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={showKeyGuide}
|
||||||
|
onClick={() => setShowKeyGuide((open) => !open)}
|
||||||
|
className="cursor-pointer font-pixel text-[0.7rem] text-gold hover:text-gold-bright hover:underline"
|
||||||
|
>
|
||||||
|
{showKeyGuide ? "Hide key guide" : `+ Unlock ${missingProviders.length} ${missingProviders.length === 1 ? "source" : "sources"}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showKeyGuide && missingProviders.length > 0 && (
|
||||||
|
<Slot className="[--cut:10px]" innerClassName="flex flex-col gap-3 p-4 text-sm">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<p className="font-display text-sm font-medium tracking-[0.06em] text-gold-bright">
|
||||||
|
Optional free API keys
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone">
|
||||||
|
Add keys to <code className="border border-gold-dim/60 bg-black px-1.5 py-0.5 font-pixel text-[0.68rem] text-ink">.env.local</code> and reload.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2.5 sm:grid-cols-3">
|
||||||
|
{missingProviders.map((p) => {
|
||||||
|
const info = PROVIDER_KEY_HINTS[p.id];
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="flex flex-col justify-between gap-2 border border-gold-dim/60 bg-black/70 p-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-ink">{p.label}</span>
|
||||||
|
{info.envVar && (
|
||||||
|
<code className="font-pixel text-[0.62rem] text-teal">{info.envVar}</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed text-stone">{info.summary}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={p.signupUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 self-start font-pixel text-[0.7rem] text-gold hover:text-gold-bright hover:underline"
|
||||||
|
>
|
||||||
|
Get free key <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Slot>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
components/SiteFooter.tsx
Normal file
211
components/SiteFooter.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { formatClock } from "@/lib/display";
|
||||||
|
import { pixelLabel } from "@/lib/styles";
|
||||||
|
import type { ProviderId, WeatherReport } from "@/lib/weather/types";
|
||||||
|
|
||||||
|
const LINK = "text-gold hover:text-gold-bright underline decoration-gold-dim underline-offset-2";
|
||||||
|
|
||||||
|
function ProviderCredit({ provider }: { provider: ProviderId }) {
|
||||||
|
switch (provider) {
|
||||||
|
case "openweather":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Weather data provided by{" "}
|
||||||
|
<a href="https://openweathermap.org/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
OpenWeather
|
||||||
|
</a>{" "}
|
||||||
|
(
|
||||||
|
<a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
CC BY-SA 4.0
|
||||||
|
</a>{" "}
|
||||||
|
/{" "}
|
||||||
|
<a href="https://opendatacommons.org/licenses/odbl/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
ODbL
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "openmeteo":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Weather data by{" "}
|
||||||
|
<a href="https://open-meteo.com/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
Open-Meteo.com
|
||||||
|
</a>{" "}
|
||||||
|
(
|
||||||
|
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
CC BY 4.0
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "weatherapi":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Powered by{" "}
|
||||||
|
<a href="https://www.weatherapi.com/" title="Free Weather API" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
WeatherAPI.com
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "tomorrow":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Powered by{" "}
|
||||||
|
<a href="https://www.tomorrow.io/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
Tomorrow.io
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible attribution, safety disclaimer, and privacy disclosure required
|
||||||
|
* across the weather, geocoding, air-quality, alert, and basemap providers.
|
||||||
|
*/
|
||||||
|
export function SiteFooter({
|
||||||
|
report,
|
||||||
|
activeProvider,
|
||||||
|
hasOpenWeather,
|
||||||
|
}: {
|
||||||
|
report: WeatherReport | null;
|
||||||
|
activeProvider: ProviderId;
|
||||||
|
hasOpenWeather: boolean;
|
||||||
|
}) {
|
||||||
|
const creditedProvider = report?.provider ?? activeProvider;
|
||||||
|
const camsYear = report ? new Date(report.fetchedAt).getUTCFullYear() : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="mt-4 flex flex-col gap-2.5 border-t border-gold-dim/50 pt-4 text-xs leading-relaxed text-stone">
|
||||||
|
{/* Live visible attribution line for active data on screen */}
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
|
<p>
|
||||||
|
<ProviderCredit provider={creditedProvider} />
|
||||||
|
{report && <>, updated {formatClock(report.fetchedAt, null)}</>}.
|
||||||
|
{report?.airQuality && (
|
||||||
|
<>
|
||||||
|
{" "}Air quality by{" "}
|
||||||
|
<a href="https://open-meteo.com/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
Open-Meteo.com
|
||||||
|
</a>{" "}
|
||||||
|
— contains modified{" "}
|
||||||
|
<a href="https://atmosphere.copernicus.eu/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
Copernicus Atmosphere Monitoring Service (CAMS)
|
||||||
|
</a>{" "}
|
||||||
|
information ({camsYear}).
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-[11px]">
|
||||||
|
{hasOpenWeather ? (
|
||||||
|
<>
|
||||||
|
Places via{" "}
|
||||||
|
<a href="https://openweathermap.org/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
OpenWeather
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Location data based on{" "}
|
||||||
|
<a href="https://www.geonames.org/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
GeoNames
|
||||||
|
</a>{" "}
|
||||||
|
(
|
||||||
|
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
CC BY 4.0
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable legal, safety & privacy notice + author signature */}
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||||
|
<details className="group w-full">
|
||||||
|
<summary className={`inline-flex cursor-pointer list-none items-center gap-1.5 ${pixelLabel} hover:text-gold-bright`}>
|
||||||
|
<span className="inline-block transition-transform group-open:rotate-90" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
Data licenses, safety disclaimer & privacy
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="mt-2.5 grid gap-3 border border-gold-dim/50 bg-panel/70 p-4 text-xs leading-relaxed sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-pixel text-[0.68rem] text-gold-bright">Safety disclaimer</p>
|
||||||
|
<p>
|
||||||
|
Weather forecasts, radar imagery, and alert summaries are provided “as is” without warranty for general
|
||||||
|
informational use. Do not rely solely on this site for life-safety or emergency decisions; monitor{" "}
|
||||||
|
<a href="https://www.weather.gov/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
weather.gov
|
||||||
|
</a>{" "}
|
||||||
|
or your national meteorological service. Not affiliated with or endorsed by NOAA/NWS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-pixel text-[0.68rem] text-gold-bright">Map & radar credits</p>
|
||||||
|
<p>
|
||||||
|
Basemap ©{" "}
|
||||||
|
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
OpenStreetMap
|
||||||
|
</a>{" "}
|
||||||
|
contributors, ©{" "}
|
||||||
|
<a href="https://carto.com/attributions" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
CARTO
|
||||||
|
</a>
|
||||||
|
. Global radar by{" "}
|
||||||
|
<a href="https://www.rainviewer.com/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
RainViewer
|
||||||
|
</a>
|
||||||
|
; US NEXRAD composite via{" "}
|
||||||
|
<a href="https://mesonet.agron.iastate.edu/" target="_blank" rel="noopener" className={LINK}>
|
||||||
|
Iowa Environmental Mesonet
|
||||||
|
</a>{" "}
|
||||||
|
(Iowa State University). Raw weather metrics are unit-converted and normalized for display.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-pixel text-[0.68rem] text-gold-bright">Privacy</p>
|
||||||
|
<p>
|
||||||
|
Wayfarer uses no accounts, ad trackers, or analytics cookies. Unit preferences and recent waypoints stay in your
|
||||||
|
browser’s <code className="font-pixel text-[0.65rem] text-ink">localStorage</code>. Coordinates shared via
|
||||||
|
“Use my location” are used only in real time to fetch forecast and map data and are never stored on
|
||||||
|
our server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-gold-dim/35 pt-2.5 text-xs">
|
||||||
|
<p className="flex items-baseline gap-2">
|
||||||
|
<span className="font-display text-xs font-bold tracking-[0.22em] text-gold uppercase">Wayfarer</span>
|
||||||
|
<a
|
||||||
|
href="https://caydens.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="group/author font-pixel text-[0.68rem] tracking-[0.05em] text-stone transition-colors hover:text-gold-bright"
|
||||||
|
>
|
||||||
|
by{" "}
|
||||||
|
<span className="text-gold underline decoration-gold-dim underline-offset-2 transition-colors group-hover/author:text-gold-bright">
|
||||||
|
Cayden Codes
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://caydens.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="font-pixel text-[0.68rem] text-stone transition-colors hover:text-gold-bright"
|
||||||
|
>
|
||||||
|
caydens.dev ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/Slot.tsx
Normal file
28
components/Slot.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const BORDERS = {
|
||||||
|
gold: "from-gold via-gold-dim to-gold",
|
||||||
|
danger: "from-heart via-heart-empty to-heart",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cut-corner frame used everywhere — an "inventory slot".
|
||||||
|
* Two layers share the same `chamfer` shape: the outer one is the 1px
|
||||||
|
* gradient border, the inner one is the dark fill.
|
||||||
|
*
|
||||||
|
* className → outer element (sizing, corner size via [--cut:…])
|
||||||
|
* innerClassName → inner element (padding, layout of the contents)
|
||||||
|
*/
|
||||||
|
export function Slot({ children, className = "", innerClassName = "px-[18px] py-4", tone = "gold", as: Tag = "div" }: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
innerClassName?: string;
|
||||||
|
tone?: keyof typeof BORDERS;
|
||||||
|
as?: "div" | "section" | "li";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tag className={`chamfer bg-linear-160 p-px ${BORDERS[tone]} ${className}`}>
|
||||||
|
<div className={`chamfer h-full bg-panel ${innerClassName}`}>{children}</div>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
337
components/WeatherApp.tsx
Normal file
337
components/WeatherApp.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchProviders, fetchWeather } from "@/lib/client-api";
|
||||||
|
import { placeLabel, type Units } from "@/lib/display";
|
||||||
|
import { outlineButton, pixelLabel } from "@/lib/styles";
|
||||||
|
import type { Place, ProviderId, ProviderInfo, WeatherReport } from "@/lib/weather/types";
|
||||||
|
import type { ForecastView } from "./forecast/ForecastPanel";
|
||||||
|
import { HomeLanding } from "./HomeLanding";
|
||||||
|
import { SkyIcon } from "./PixelArt";
|
||||||
|
import { PlaceSearch } from "./PlaceSearch";
|
||||||
|
import { ProviderPicker } from "./ProviderPicker";
|
||||||
|
import { RadarPanel } from "./radar/RadarPanel";
|
||||||
|
import { SiteFooter } from "./SiteFooter";
|
||||||
|
import { Slot } from "./Slot";
|
||||||
|
import { WeatherView } from "./WeatherView";
|
||||||
|
|
||||||
|
/** Settings that survive a page reload (stored in this browser only). */
|
||||||
|
interface Settings {
|
||||||
|
provider: ProviderId;
|
||||||
|
units: Units;
|
||||||
|
place: Place | null;
|
||||||
|
recentPlaces: Place[];
|
||||||
|
forecastView: ForecastView;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "wayfarer:settings";
|
||||||
|
const MAX_RECENT_PLACES = 5;
|
||||||
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
provider: "openweather",
|
||||||
|
units: "imperial",
|
||||||
|
place: null,
|
||||||
|
recentPlaces: [],
|
||||||
|
forecastView: "hourly",
|
||||||
|
};
|
||||||
|
|
||||||
|
function samePlace(a: Place, b: Place): boolean {
|
||||||
|
return Math.abs(a.lat - b.lat) < 0.01 && Math.abs(a.lon - b.lon) < 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings(): Settings {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!saved) return DEFAULT_SETTINGS;
|
||||||
|
const parsed = JSON.parse(saved) as Partial<Settings>;
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...parsed,
|
||||||
|
recentPlaces: Array.isArray(parsed.recentPlaces) ? parsed.recentPlaces.slice(0, MAX_RECENT_PLACES) : [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SETTINGS; // private mode or blocked storage — just use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings(settings: Settings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch {
|
||||||
|
/* not critical */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export default function WeatherApp() {
|
||||||
|
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||||
|
const [report, setReport] = useState<WeatherReport | null>(null);
|
||||||
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const update = (changes: Partial<Settings>) => setSettings((prev) => ({ ...prev, ...changes }));
|
||||||
|
|
||||||
|
function selectPlace(nextPlace: Place) {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const filtered = prev.recentPlaces.filter((p) => !samePlace(p, nextPlace));
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
place: nextPlace,
|
||||||
|
recentPlaces: [nextPlace, ...filtered].slice(0, MAX_RECENT_PLACES),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecentPlace(target: Place) {
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
recentPlaces: prev.recentPlaces.filter((p) => !samePlace(p, target)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
setReport(null);
|
||||||
|
setStatus("idle");
|
||||||
|
setError(null);
|
||||||
|
update({ place: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. On first load: restore saved settings and find out which providers have keys.
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadSettings();
|
||||||
|
|
||||||
|
fetchProviders()
|
||||||
|
.then((list) => {
|
||||||
|
setProviders(list);
|
||||||
|
// Prefer OpenWeather if its key was just added and user hasn't explicitly saved a choice;
|
||||||
|
// otherwise keep saved provider if ready, or fall back to the first ready source.
|
||||||
|
const savedIsReady = list.some((p) => p.id === saved.provider && p.ready);
|
||||||
|
const fallback = list.find((p) => p.ready)?.id ?? saved.provider;
|
||||||
|
setSettings({ ...saved, provider: savedIsReady ? saved.provider : fallback });
|
||||||
|
})
|
||||||
|
.catch(() => setSettings(saved))
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 2. Remember settings whenever they change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (loaded) saveSettings(settings);
|
||||||
|
}, [settings, loaded]);
|
||||||
|
|
||||||
|
// 3. Fetch weather whenever the place or provider changes.
|
||||||
|
const { place, provider } = settings;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded || !place) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setStatus("loading");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
fetchWeather(provider, place, controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
setReport(data);
|
||||||
|
setStatus("ready");
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(err.message);
|
||||||
|
setStatus("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [loaded, place, provider]);
|
||||||
|
|
||||||
|
const activeProvider = providers.find((p) => p.id === settings.provider);
|
||||||
|
const reportProvider = providers.find((p) => p.id === report?.provider);
|
||||||
|
const hasOpenWeather = providers.some((p) => p.id === "openweather" && p.ready);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className={[
|
||||||
|
"mx-auto flex w-full max-w-[880px] flex-col gap-5",
|
||||||
|
// --gutter is reused by the forecast strip to bleed to the screen edge
|
||||||
|
"[--gutter:max(16px,env(safe-area-inset-left))] px-(--gutter)",
|
||||||
|
"pt-[max(20px,env(safe-area-inset-top))] pb-[max(40px,env(safe-area-inset-bottom))]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* Top station bar */}
|
||||||
|
<header className="flex items-center justify-between gap-3 border-b border-gold-dim/50 pb-3.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-3.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{place ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
title="Return to Wayfarer home"
|
||||||
|
aria-label="Return to Wayfarer home"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CompassStar />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<CompassStar />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{place ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
title="Return to Wayfarer home"
|
||||||
|
className="cursor-pointer text-left font-display text-lg leading-none font-bold tracking-[0.28em] text-gold uppercase transition-colors hover:text-gold-bright sm:text-2xl"
|
||||||
|
>
|
||||||
|
Wayfarer
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h1 className="font-display text-lg leading-none font-bold tracking-[0.28em] text-gold uppercase sm:text-2xl">
|
||||||
|
Wayfarer
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="https://caydens.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="group/author mt-1 w-fit font-pixel text-[0.68rem] leading-none tracking-[0.04em] text-stone transition-colors hover:text-gold-bright"
|
||||||
|
>
|
||||||
|
by <span className="text-gold transition-colors group-hover/author:text-gold-bright">Cayden Codes</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="hidden border-l border-gold-dim/60 pl-3.5 text-xs text-stone sm:inline">
|
||||||
|
Weather for the road ahead
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{place && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
className="min-h-9 cursor-pointer border border-gold-dim bg-panel/60 px-2.5 font-pixel text-[0.72rem] text-stone transition-colors hover:border-gold hover:text-gold-bright"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex divide-x divide-gold-dim border border-gold-dim bg-panel/60" role="group" aria-label="Temperature units">
|
||||||
|
{(["imperial", "metric"] as const).map((u) => (
|
||||||
|
<button
|
||||||
|
key={u}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={settings.units === u}
|
||||||
|
onClick={() => update({ units: u })}
|
||||||
|
className="min-h-9 min-w-11 cursor-pointer px-2.5 font-pixel text-[0.8rem] text-stone transition-colors hover:not-aria-pressed:text-ink aria-pressed:bg-gold aria-pressed:text-black"
|
||||||
|
>
|
||||||
|
{u === "imperial" ? "°F" : "°C"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Search & recent waypoints */}
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<PlaceSearch inputRef={searchInputRef} onSelect={selectPlace} geocoderUsesOpenWeather={hasOpenWeather} />
|
||||||
|
|
||||||
|
{settings.recentPlaces.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none]" aria-label="Recent waypoints">
|
||||||
|
<span className={`${pixelLabel} flex-none`}>Recent</span>
|
||||||
|
{settings.recentPlaces.map((item) => {
|
||||||
|
const isCurrent = place ? samePlace(place, item) : false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.lat.toFixed(2)},${item.lon.toFixed(2)}`}
|
||||||
|
className={`flex flex-none items-center border text-xs transition-colors ${
|
||||||
|
isCurrent
|
||||||
|
? "border-gold bg-gold/12 text-gold-bright"
|
||||||
|
: "border-gold-dim/70 bg-panel/60 text-stone hover:border-gold/60 hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectPlace(item)}
|
||||||
|
className="min-h-7 cursor-pointer px-2.5 py-1 font-pixel text-[0.68rem]"
|
||||||
|
>
|
||||||
|
{placeLabel(item)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove ${placeLabel(item)} from recent`}
|
||||||
|
onClick={() => removeRecentPlace(item)}
|
||||||
|
className="min-h-7 cursor-pointer pr-2 pl-1 font-pixel text-[0.68rem] opacity-60 hover:text-gold-bright hover:opacity-100"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providers.length > 0 && (
|
||||||
|
<ProviderPicker providers={providers} selected={settings.provider} onChange={(id) => update({ provider: id })} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Homepage landing state when no place is selected */}
|
||||||
|
{!place && (
|
||||||
|
<HomeLanding
|
||||||
|
onSelectPlace={selectPlace}
|
||||||
|
onFocusSearch={() => searchInputRef.current?.focus()}
|
||||||
|
activeProviderLabel={activeProvider?.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "error" && error && (
|
||||||
|
<Slot as="section" tone="danger" innerClassName="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button type="button" className={outlineButton} onClick={() => update({ place: place ? { ...place } : null })}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</Slot>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Initial loading frame sits ABOVE radar so the radar doesn't jump down once forecast arrives */}
|
||||||
|
{place && status === "loading" && !report && (
|
||||||
|
<Slot as="section" className="[--cut:22px]" innerClassName="flex items-center gap-5 p-6 sm:px-8 sm:py-8">
|
||||||
|
<SkyIcon condition="clear" size={56} className="flex-none animate-float motion-reduce:animate-none" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-display text-lg text-gold-bright">Reading the skies above {placeLabel(place)}…</p>
|
||||||
|
<p className={pixelLabel}>Gathering current conditions, hourly horizon, and local alerts</p>
|
||||||
|
</div>
|
||||||
|
</Slot>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keep the last report on screen while a new one loads, just dimmed */}
|
||||||
|
{report && place && status !== "error" && (
|
||||||
|
<div className={status === "loading" ? "opacity-50 transition-opacity" : undefined} aria-busy={status === "loading"}>
|
||||||
|
<WeatherView
|
||||||
|
report={report}
|
||||||
|
place={place}
|
||||||
|
units={settings.units}
|
||||||
|
forecastView={settings.forecastView}
|
||||||
|
onForecastViewChange={(view) => update({ forecastView: view })}
|
||||||
|
providerLabel={reportProvider?.label ?? report.provider}
|
||||||
|
maxDaysOnPlan={reportProvider?.forecastDays ?? report.daily.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The radar doesn't depend on the forecast, so it shows as soon as there's a place */}
|
||||||
|
{place && <RadarPanel place={place} timeZone={report?.timeZone ?? null} hasOpenWeather={hasOpenWeather} />}
|
||||||
|
|
||||||
|
<SiteFooter report={report} activeProvider={settings.provider} hasOpenWeather={hasOpenWeather} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four-point gold compass emblem in the station header. */
|
||||||
|
function CompassStar() {
|
||||||
|
return (
|
||||||
|
<svg width={20} height={20} viewBox="0 0 20 20" fill="none" aria-hidden="true" className="flex-none text-gold">
|
||||||
|
<path d="M10 1L12.3 7.7L19 10L12.3 12.3L10 19L7.7 12.3L1 10L7.7 7.7L10 1Z" stroke="currentColor" strokeWidth="1.2" fill="var(--color-panel)" />
|
||||||
|
<circle cx="10" cy="10" r="1.6" fill="var(--color-gold-bright)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/WeatherView.tsx
Normal file
84
components/WeatherView.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { comfortHearts, formatClock, formatCoords, formatTemp, placeLabel, precipOutlook, type Units } from "@/lib/display";
|
||||||
|
import { pixelLabel } from "@/lib/styles";
|
||||||
|
import type { Place, WeatherReport } from "@/lib/weather/types";
|
||||||
|
import { AlertsBanner } from "./AlertsBanner";
|
||||||
|
import { ConditionsGrid } from "./ConditionsGrid";
|
||||||
|
import { ForecastPanel, type ForecastView } from "./forecast/ForecastPanel";
|
||||||
|
import { HeartMeter, SkyIcon } from "./PixelArt";
|
||||||
|
import { Slot } from "./Slot";
|
||||||
|
|
||||||
|
/** Everything shown once a report has loaded: alerts, hero, conditions, forecast. */
|
||||||
|
export function WeatherView({ report, place, units, forecastView, onForecastViewChange, providerLabel, maxDaysOnPlan }: {
|
||||||
|
report: WeatherReport;
|
||||||
|
place: Place;
|
||||||
|
units: Units;
|
||||||
|
forecastView: ForecastView;
|
||||||
|
onForecastViewChange: (view: ForecastView) => void;
|
||||||
|
providerLabel: string;
|
||||||
|
maxDaysOnPlan: number;
|
||||||
|
}) {
|
||||||
|
const { current, daily } = report;
|
||||||
|
const today = daily[0];
|
||||||
|
const outlook = precipOutlook(report.hourly, report.timeZone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<AlertsBanner alerts={report.alerts} source={report.alertSource} timeZone={report.timeZone} />
|
||||||
|
|
||||||
|
{/* Hero: stacks cleanly on phones, side-by-side from sm up */}
|
||||||
|
<Slot
|
||||||
|
as="section"
|
||||||
|
className="[--cut:22px]"
|
||||||
|
innerClassName="grid items-center gap-5 p-6 sm:grid-cols-[auto_1fr] sm:gap-7 sm:px-8 sm:py-7"
|
||||||
|
>
|
||||||
|
<div className="grid aspect-square w-[132px] place-items-center justify-self-center border border-gold-dim bg-black bg-radial-[circle_at_50%_45%] from-gold-bright/12 to-transparent to-65% sm:w-[168px] sm:justify-self-start">
|
||||||
|
<SkyIcon
|
||||||
|
condition={current.condition}
|
||||||
|
isDay={current.isDay}
|
||||||
|
size={132}
|
||||||
|
className="h-auto w-[100px] animate-float motion-reduce:animate-none sm:w-[132px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
|
<h2 className="font-display text-lg font-medium tracking-[0.03em] text-ink sm:text-xl">
|
||||||
|
{placeLabel(place)}
|
||||||
|
</h2>
|
||||||
|
<p className={pixelLabel}>
|
||||||
|
Local {formatClock(report.fetchedAt, report.timeZone)} · {formatCoords(place.lat, place.lon)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 font-display text-[clamp(4.5rem,16vw,7rem)] leading-[0.95] font-medium tracking-[-0.02em] text-gold-bright">
|
||||||
|
{formatTemp(current.tempC, units)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-lg first-letter:uppercase">
|
||||||
|
{current.description}
|
||||||
|
{today && (
|
||||||
|
<span className="text-stone">
|
||||||
|
{" "}· High {formatTemp(today.highC, units)}, low {formatTemp(today.lowC, units)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{outlook && <p className="mt-1 text-sm text-teal">{outlook}</p>}
|
||||||
|
<div className="mt-4 flex items-center gap-3 border-t border-gold-dim/40 pt-3">
|
||||||
|
<HeartMeter hearts={comfortHearts(current)} />
|
||||||
|
<span className={pixelLabel}>Comfort</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Slot>
|
||||||
|
|
||||||
|
<ConditionsGrid report={report} units={units} />
|
||||||
|
|
||||||
|
<ForecastPanel
|
||||||
|
report={report}
|
||||||
|
units={units}
|
||||||
|
view={forecastView}
|
||||||
|
onViewChange={onForecastViewChange}
|
||||||
|
providerLabel={providerLabel}
|
||||||
|
maxDaysOnPlan={maxDaysOnPlan}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
components/forecast/DailyView.tsx
Normal file
122
components/forecast/DailyView.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
compass, dayName, formatClock, formatDuration, formatPrecip, formatShortDate, formatTemp, formatWind,
|
||||||
|
tempValue, uvLabel, type Units,
|
||||||
|
} from "@/lib/display";
|
||||||
|
import { pixelLabel } from "@/lib/styles";
|
||||||
|
import type { DailyForecast } from "@/lib/weather/types";
|
||||||
|
import { SkyIcon } from "../PixelArt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daily forecast (up to 14 days) as rows:
|
||||||
|
*
|
||||||
|
* Today Oct 21 [icon] 60% 39° ━━━━━█████━━━━ 52°
|
||||||
|
*
|
||||||
|
* The bar shows each day's low→high against the whole period's range,
|
||||||
|
* so warm and cold days stand out at a glance. Tap a row for its details.
|
||||||
|
*/
|
||||||
|
export function DailyView({ daily, units, timeZone, providerLabel, maxDaysOnPlan }: {
|
||||||
|
daily: DailyForecast[];
|
||||||
|
units: Units;
|
||||||
|
timeZone: string | null;
|
||||||
|
providerLabel: string;
|
||||||
|
maxDaysOnPlan: number;
|
||||||
|
}) {
|
||||||
|
const [openDay, setOpenDay] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (daily.length === 0) {
|
||||||
|
return <p className={pixelLabel}>Daily forecast unavailable for this location.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodLow = Math.min(...daily.map((d) => tempValue(d.lowC, units)));
|
||||||
|
const periodHigh = Math.max(...daily.map((d) => tempValue(d.highC, units)));
|
||||||
|
const span = Math.max(1, periodHigh - periodLow);
|
||||||
|
const percentAlong = (celsius: number) => ((tempValue(celsius, units) - periodLow) / span) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ol className="border border-gold-dim">
|
||||||
|
{daily.map((day) => {
|
||||||
|
const isOpen = openDay === day.time;
|
||||||
|
return (
|
||||||
|
<li key={day.time} className="border-t border-gold-dim/50 first:border-t-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => setOpenDay(isOpen ? null : day.time)}
|
||||||
|
className="grid w-full cursor-pointer grid-cols-[4.5rem_28px_2.5rem_2.25rem_1fr_2.25rem_14px] items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-gold/6 sm:grid-cols-[7rem_32px_3rem_2.5rem_1fr_2.5rem_16px] sm:gap-3 sm:px-4"
|
||||||
|
>
|
||||||
|
<span className="leading-tight">
|
||||||
|
<span className="block font-medium">{dayName(day.time, timeZone)}</span>
|
||||||
|
<span className="block text-xs text-stone">{formatShortDate(day.time, timeZone)}</span>
|
||||||
|
</span>
|
||||||
|
<SkyIcon condition={day.condition} size={28} />
|
||||||
|
<span className="font-pixel text-xs text-teal">
|
||||||
|
{day.precipChance !== null && day.precipChance >= 10 ? `${day.precipChance}%` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-display text-stone tabular-nums">{formatTemp(day.lowC, units)}</span>
|
||||||
|
{/* Range bar: the gold segment spans this day's low → high */}
|
||||||
|
<span className="relative h-1.5 bg-gold-dim/40" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
className="absolute inset-y-0 bg-linear-to-r from-gold to-gold-bright"
|
||||||
|
style={{ left: `${percentAlong(day.lowC)}%`, right: `${100 - percentAlong(day.highC)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-gold-bright tabular-nums">{formatTemp(day.highC, units)}</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`justify-self-end font-pixel text-xs text-stone transition-transform duration-150 ${isOpen ? "rotate-90 text-gold-bright" : ""}`}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && <DayDetails day={day} units={units} timeZone={timeZone} />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{daily.length < 14 && (
|
||||||
|
<p className={pixelLabel}>
|
||||||
|
{providerLabel} gives {maxDaysOnPlan} days on this plan. Choose Open-Meteo for 14.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayDetails({ day, units, timeZone }: { day: DailyForecast; units: Units; timeZone: string | null }) {
|
||||||
|
const wind =
|
||||||
|
day.windKph === null
|
||||||
|
? null
|
||||||
|
: `${formatWind(day.windKph, units)}${day.windDeg !== null ? ` from ${compass(day.windDeg)}` : ""}${day.gustKph ? `, gusts ${formatWind(day.gustKph, units)}` : ""}`;
|
||||||
|
|
||||||
|
const facts: [string, string | null][] = [
|
||||||
|
["Precipitation", day.precipMm !== null ? `${formatPrecip(day.precipMm, units)}${day.precipChance !== null ? ` (${day.precipChance}% chance)` : ""}` : null],
|
||||||
|
["Wind", wind],
|
||||||
|
["UV index", day.uvIndexMax !== null ? `${Math.round(day.uvIndexMax)} ${uvLabel(day.uvIndexMax)}` : null],
|
||||||
|
["Humidity", day.humidity !== null ? `${Math.round(day.humidity)}%` : null],
|
||||||
|
["Sunrise", day.sunrise ? formatClock(day.sunrise, timeZone) : null],
|
||||||
|
["Sunset", day.sunset ? formatClock(day.sunset, timeZone) : null],
|
||||||
|
["Daylight", day.sunrise && day.sunset ? formatDuration(day.sunrise, day.sunset) : null],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gold-dim/30 bg-black px-4 pt-3 pb-4">
|
||||||
|
{day.summary && <p className="mb-2 first-letter:uppercase">{day.summary}</p>}
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-4">
|
||||||
|
{facts
|
||||||
|
.filter(([, value]) => value !== null)
|
||||||
|
.map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<dt className={pixelLabel}>{label}</dt>
|
||||||
|
<dd className="text-sm">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/forecast/ForecastPanel.tsx
Normal file
48
components/forecast/ForecastPanel.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Units } from "@/lib/display";
|
||||||
|
import { sectionHeading } from "@/lib/styles";
|
||||||
|
import type { WeatherReport } from "@/lib/weather/types";
|
||||||
|
import { ChoiceChips } from "../ui/ChoiceChips";
|
||||||
|
import { DailyView } from "./DailyView";
|
||||||
|
import { HourlyView } from "./HourlyView";
|
||||||
|
|
||||||
|
export type ForecastView = "hourly" | "daily";
|
||||||
|
|
||||||
|
/** The forecast section with a switch between the hourly and daily views. */
|
||||||
|
export function ForecastPanel({ report, units, view, onViewChange, providerLabel, maxDaysOnPlan }: {
|
||||||
|
report: WeatherReport;
|
||||||
|
units: Units;
|
||||||
|
view: ForecastView;
|
||||||
|
onViewChange: (view: ForecastView) => void;
|
||||||
|
providerLabel: string;
|
||||||
|
maxDaysOnPlan: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="forecast-heading" className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<h2 id="forecast-heading" className={sectionHeading}>
|
||||||
|
The days ahead
|
||||||
|
</h2>
|
||||||
|
<span className="hidden h-px flex-1 bg-linear-to-r from-gold-dim/70 to-transparent sm:block" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<ChoiceChips
|
||||||
|
legend="Forecast view"
|
||||||
|
selected={view}
|
||||||
|
onChange={onViewChange}
|
||||||
|
choices={[
|
||||||
|
{ value: "hourly", label: "Hourly" },
|
||||||
|
{ value: "daily", label: "Daily", note: `${report.daily.length}d` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === "hourly" ? (
|
||||||
|
<HourlyView hourly={report.hourly} stepHours={report.hourlyStepHours} units={units} timeZone={report.timeZone} />
|
||||||
|
) : (
|
||||||
|
<DailyView daily={report.daily} units={units} timeZone={report.timeZone} providerLabel={providerLabel} maxDaysOnPlan={maxDaysOnPlan} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
components/forecast/HourlyChart.tsx
Normal file
145
components/forecast/HourlyChart.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { KeyboardEvent, PointerEvent } from "react";
|
||||||
|
import { dayName, formatHour, localDayKey, tempValue, type Units } from "@/lib/display";
|
||||||
|
import type { HourlyForecast } from "@/lib/weather/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-drawn SVG chart in two stacked bands that share one time axis:
|
||||||
|
*
|
||||||
|
* ┌ Thu ───────────────┬ Fri ─────────────┐ day dividers + names
|
||||||
|
* │ temperature line (gold) and │ band 1: °F or °C
|
||||||
|
* │ feels-like line (dashed) │
|
||||||
|
* ├───────────────────────────────────────┤
|
||||||
|
* │ ▮ ▮▮▮ chance of precipitation │ band 2: 0–100 %
|
||||||
|
* └ 3 PM 6 PM 9 PM ...────────────────┘ hour labels
|
||||||
|
*
|
||||||
|
* Two bands instead of two y-axes on one plot, because a dual axis invites
|
||||||
|
* reading meaning into where the lines happen to cross.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PAD_X = 16;
|
||||||
|
const DAY_LABEL_Y = 14;
|
||||||
|
const TEMP_TOP = 40;
|
||||||
|
const TEMP_HEIGHT = 110;
|
||||||
|
const PRECIP_TITLE_Y = TEMP_TOP + TEMP_HEIGHT + 30;
|
||||||
|
const PRECIP_TOP = PRECIP_TITLE_Y + 10;
|
||||||
|
const PRECIP_HEIGHT = 56;
|
||||||
|
const HOUR_LABEL_Y = PRECIP_TOP + PRECIP_HEIGHT + 18;
|
||||||
|
const HEIGHT = HOUR_LABEL_Y + 8;
|
||||||
|
|
||||||
|
// Theme colors from globals.css. SVG fill/stroke attributes accept CSS variables.
|
||||||
|
const GOLD = "var(--color-gold-bright)";
|
||||||
|
const STONE = "var(--color-stone)";
|
||||||
|
const TEAL = "var(--color-teal)";
|
||||||
|
const RULE = "var(--color-gold-dim)";
|
||||||
|
|
||||||
|
export function HourlyChart({ hours, units, timeZone, pxPerPoint, labelEvery, selected, onSelect }: {
|
||||||
|
hours: HourlyForecast[];
|
||||||
|
units: Units;
|
||||||
|
timeZone: string | null;
|
||||||
|
pxPerPoint: number;
|
||||||
|
labelEvery: number; // label every Nth point
|
||||||
|
selected: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
}) {
|
||||||
|
const width = PAD_X * 2 + hours.length * pxPerPoint;
|
||||||
|
const x = (i: number) => PAD_X + i * pxPerPoint + pxPerPoint / 2;
|
||||||
|
|
||||||
|
// Temperature scale covers both lines, with a little headroom for labels.
|
||||||
|
const temps = hours.flatMap((h) => [tempValue(h.tempC, units), tempValue(h.feelsLikeC ?? h.tempC, units)]);
|
||||||
|
const low = Math.min(...temps) - 1;
|
||||||
|
const high = Math.max(...temps) + 1;
|
||||||
|
const yTemp = (celsius: number) => TEMP_TOP + TEMP_HEIGHT - ((tempValue(celsius, units) - low) / (high - low)) * TEMP_HEIGHT;
|
||||||
|
const yPrecip = (percent: number) => PRECIP_TOP + PRECIP_HEIGHT - (percent / 100) * PRECIP_HEIGHT;
|
||||||
|
|
||||||
|
const tempPath = hours.map((h, i) => `${i === 0 ? "M" : "L"}${x(i)},${yTemp(h.tempC)}`).join(" ");
|
||||||
|
const hasFeelsLike = hours.some((h) => h.feelsLikeC !== null);
|
||||||
|
const feelsPath = hours.map((h, i) => `${i === 0 ? "M" : "L"}${x(i)},${yTemp(h.feelsLikeC ?? h.tempC)}`).join(" ");
|
||||||
|
|
||||||
|
// A divider wherever the local calendar day changes.
|
||||||
|
const dayStarts = hours
|
||||||
|
.map((h, i) => ({ i, day: localDayKey(h.time, timeZone) }))
|
||||||
|
.filter(({ i, day }) => i === 0 || day !== localDayKey(hours[i - 1].time, timeZone));
|
||||||
|
|
||||||
|
// Pointer and keyboard both move the crosshair.
|
||||||
|
function selectFromPointer(event: PointerEvent<SVGSVGElement>) {
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
const index = Math.floor((event.clientX - box.left - PAD_X) / pxPerPoint);
|
||||||
|
onSelect(Math.min(hours.length - 1, Math.max(0, index)));
|
||||||
|
}
|
||||||
|
function selectFromKeys(event: KeyboardEvent<SVGSVGElement>) {
|
||||||
|
if (event.key === "ArrowRight") onSelect(Math.min(hours.length - 1, selected + 1));
|
||||||
|
else if (event.key === "ArrowLeft") onSelect(Math.max(0, selected - 1));
|
||||||
|
else return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={HEIGHT}
|
||||||
|
viewBox={`0 0 ${width} ${HEIGHT}`}
|
||||||
|
className="block touch-pan-x font-pixel select-none focus-visible:outline-offset-0"
|
||||||
|
tabIndex={0}
|
||||||
|
role="img"
|
||||||
|
aria-label="Hourly temperature and chance of precipitation. Use left and right arrow keys to step through hours."
|
||||||
|
onPointerMove={selectFromPointer}
|
||||||
|
onPointerDown={selectFromPointer}
|
||||||
|
onKeyDown={selectFromKeys}
|
||||||
|
>
|
||||||
|
{/* Day dividers and names */}
|
||||||
|
{dayStarts.map(({ i }) => (
|
||||||
|
<g key={i}>
|
||||||
|
{i > 0 && <line x1={x(i) - pxPerPoint / 2} x2={x(i) - pxPerPoint / 2} y1={4} y2={HOUR_LABEL_Y - 12} stroke={RULE} strokeDasharray="2 3" />}
|
||||||
|
<text x={x(i) - pxPerPoint / 2 + 6} y={DAY_LABEL_Y} fontSize={10} fill={STONE}>
|
||||||
|
{dayName(hours[i].time, timeZone)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Band 1: temperature */}
|
||||||
|
{hasFeelsLike && <path d={feelsPath} fill="none" stroke={STONE} strokeWidth={1.5} strokeDasharray="4 3" />}
|
||||||
|
<path d={tempPath} fill="none" stroke={GOLD} strokeWidth={2} strokeLinejoin="round" />
|
||||||
|
{hours.map((h, i) =>
|
||||||
|
i % labelEvery === 0 ? (
|
||||||
|
<text key={i} x={x(i)} y={yTemp(h.tempC) - 8} fontSize={10} fill="var(--color-ink)" textAnchor="middle">
|
||||||
|
{Math.round(tempValue(h.tempC, units))}°
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Band 2: chance of precipitation */}
|
||||||
|
<text x={PAD_X} y={PRECIP_TITLE_Y} fontSize={9} fill={STONE}>
|
||||||
|
Chance of precipitation
|
||||||
|
</text>
|
||||||
|
<line x1={PAD_X} x2={width - PAD_X} y1={PRECIP_TOP + PRECIP_HEIGHT} y2={PRECIP_TOP + PRECIP_HEIGHT} stroke={RULE} />
|
||||||
|
{hours.map((h, i) =>
|
||||||
|
h.precipChance ? (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={x(i) - Math.max(2, pxPerPoint - 4) / 2}
|
||||||
|
width={Math.max(2, pxPerPoint - 4)}
|
||||||
|
y={yPrecip(h.precipChance)}
|
||||||
|
height={(h.precipChance / 100) * PRECIP_HEIGHT}
|
||||||
|
fill={TEAL}
|
||||||
|
opacity={i === selected ? 1 : 0.7}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hour labels */}
|
||||||
|
{hours.map((h, i) =>
|
||||||
|
i % labelEvery === 0 ? (
|
||||||
|
<text key={i} x={x(i)} y={HOUR_LABEL_Y} fontSize={9} fill={STONE} textAnchor="middle">
|
||||||
|
{formatHour(h.time, timeZone)}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Crosshair on the selected hour */}
|
||||||
|
<line x1={x(selected)} x2={x(selected)} y1={TEMP_TOP - 10} y2={PRECIP_TOP + PRECIP_HEIGHT} stroke={GOLD} strokeOpacity={0.45} />
|
||||||
|
<circle cx={x(selected)} cy={yTemp(hours[selected].tempC)} r={4} fill="var(--color-black)" stroke={GOLD} strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
components/forecast/HourlyView.tsx
Normal file
154
components/forecast/HourlyView.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { compass, dayName, formatHour, formatPrecip, formatTemp, formatWind, uvLabel, type Units } from "@/lib/display";
|
||||||
|
import { pixelLabel } from "@/lib/styles";
|
||||||
|
import type { HourlyForecast } from "@/lib/weather/types";
|
||||||
|
import { SkyIcon } from "../PixelArt";
|
||||||
|
import { ChoiceChips } from "../ui/ChoiceChips";
|
||||||
|
import { HourlyChart } from "./HourlyChart";
|
||||||
|
|
||||||
|
type Range = "24" | "48" | "168";
|
||||||
|
type Mode = "chart" | "table";
|
||||||
|
|
||||||
|
/** Chart sizing per range, in pixels per hour, so longer ranges stay scrollable but compact. */
|
||||||
|
const PX_PER_HOUR: Record<Range, number> = { "24": 34, "48": 22, "168": 10 };
|
||||||
|
const LABEL_EVERY_HOURS: Record<Range, number> = { "24": 3, "48": 6, "168": 12 };
|
||||||
|
|
||||||
|
/** Hourly forecast: range picker, chart or table, and a readout of the selected hour. */
|
||||||
|
export function HourlyView({ hourly, stepHours, units, timeZone }: {
|
||||||
|
hourly: HourlyForecast[];
|
||||||
|
stepHours: 1 | 3;
|
||||||
|
units: Units;
|
||||||
|
timeZone: string | null;
|
||||||
|
}) {
|
||||||
|
const [range, setRange] = useState<Range>("48");
|
||||||
|
const [mode, setMode] = useState<Mode>("chart");
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
|
||||||
|
if (hourly.length === 0) {
|
||||||
|
return <p className={pixelLabel}>Hourly forecast unavailable for this location.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableHours = hourly.length * stepHours;
|
||||||
|
const ranges: { value: Range; label: string }[] = [
|
||||||
|
{ value: "24", label: "24 hours" },
|
||||||
|
{ value: "48", label: "48 hours" },
|
||||||
|
{ value: "168", label: "7 days" },
|
||||||
|
];
|
||||||
|
// Offer a range only if the provider delivers at least most of it.
|
||||||
|
const offered = ranges.filter((r) => r.value === "24" || availableHours >= Number(r.value) * 0.75);
|
||||||
|
const activeRange = offered.some((r) => r.value === range) ? range : offered[offered.length - 1].value;
|
||||||
|
|
||||||
|
const hours = hourly.slice(0, Math.ceil(Number(activeRange) / stepHours));
|
||||||
|
const current = hours[Math.min(selected, hours.length - 1)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<ChoiceChips legend="Hourly range" size="sm" choices={offered} selected={activeRange} onChange={(r) => { setRange(r); setSelected(0); }} />
|
||||||
|
<ChoiceChips
|
||||||
|
legend="Show as"
|
||||||
|
size="sm"
|
||||||
|
choices={[{ value: "chart", label: "Chart" }, { value: "table", label: "Table" }]}
|
||||||
|
selected={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stepHours === 3 && <p className={pixelLabel}>This provider forecasts in 3-hour steps.</p>}
|
||||||
|
|
||||||
|
{mode === "chart" ? (
|
||||||
|
<>
|
||||||
|
<HourReadout hour={current} units={units} timeZone={timeZone} />
|
||||||
|
{/* Legend: two series share the temperature band */}
|
||||||
|
<div className="flex gap-4 text-xs text-stone" aria-hidden="true">
|
||||||
|
<span className="flex items-center gap-1.5"><span className="h-0.5 w-5 bg-gold-bright" />Temperature</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="w-5 border-t-[1.5px] border-dashed border-stone" />Feels like</span>
|
||||||
|
</div>
|
||||||
|
<div className="-mx-(--gutter) overflow-x-auto px-(--gutter) sm:mx-0 sm:px-0">
|
||||||
|
<HourlyChart
|
||||||
|
hours={hours}
|
||||||
|
units={units}
|
||||||
|
timeZone={timeZone}
|
||||||
|
pxPerPoint={PX_PER_HOUR[activeRange] * stepHours}
|
||||||
|
labelEvery={Math.max(1, LABEL_EVERY_HOURS[activeRange] / stepHours)}
|
||||||
|
selected={Math.min(selected, hours.length - 1)}
|
||||||
|
onSelect={setSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<HourlyTable hours={hours} units={units} timeZone={timeZone} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The details of one hour, shown above the chart and driven by the crosshair. */
|
||||||
|
function HourReadout({ hour, units, timeZone }: { hour: HourlyForecast; units: Units; timeZone: string | null }) {
|
||||||
|
const facts = [
|
||||||
|
hour.feelsLikeC !== null && ["Feels like", formatTemp(hour.feelsLikeC, units)],
|
||||||
|
hour.precipChance !== null && ["Precip", `${hour.precipChance}%${hour.precipMm ? ` · ${formatPrecip(hour.precipMm, units)}` : ""}`],
|
||||||
|
["Wind", `${formatWind(hour.windKph, units)}${hour.windDeg !== null ? ` ${compass(hour.windDeg)}` : ""}${hour.gustKph ? `, gusts ${formatWind(hour.gustKph, units)}` : ""}`],
|
||||||
|
hour.humidity !== null && ["Humidity", `${hour.humidity}%`],
|
||||||
|
hour.uvIndex !== null && ["UV", `${Math.round(hour.uvIndex)} ${uvLabel(hour.uvIndex)}`],
|
||||||
|
].filter(Boolean) as [string, string][];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 border border-gold-dim bg-black p-3" aria-live="polite">
|
||||||
|
<SkyIcon condition={hour.condition} isDay={hour.isDay} size={40} className="flex-none" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-display text-lg text-gold-bright">
|
||||||
|
{dayName(hour.time, timeZone)} {formatHour(hour.time, timeZone)} · {formatTemp(hour.tempC, units)}
|
||||||
|
</p>
|
||||||
|
<dl className="flex flex-wrap gap-x-4 gap-y-0.5 text-sm">
|
||||||
|
{facts.map(([label, value]) => (
|
||||||
|
<div key={label} className="flex gap-1.5">
|
||||||
|
<dt className="text-stone">{label}</dt>
|
||||||
|
<dd>{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every hour as a table row — the accessible, exact-numbers view of the chart. */
|
||||||
|
function HourlyTable({ hours, units, timeZone }: { hours: HourlyForecast[]; units: Units; timeZone: string | null }) {
|
||||||
|
const cell = "px-3 py-2 text-right tabular-nums whitespace-nowrap";
|
||||||
|
return (
|
||||||
|
<div className="max-h-[480px] overflow-auto border border-gold-dim">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead className="sticky top-0 bg-black font-pixel text-[0.7rem] text-stone">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">Time</th>
|
||||||
|
<th className={`${cell} font-normal`}>Temp</th>
|
||||||
|
<th className={`${cell} font-normal`}>Feels</th>
|
||||||
|
<th className={`${cell} font-normal`}>Precip</th>
|
||||||
|
<th className={`${cell} font-normal`}>Wind</th>
|
||||||
|
<th className={`${cell} font-normal`}>Humidity</th>
|
||||||
|
<th className={`${cell} font-normal`}>UV</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{hours.map((h) => (
|
||||||
|
<tr key={h.time} className="border-t border-gold-dim/50">
|
||||||
|
<td className="flex items-center gap-2 px-3 py-2 whitespace-nowrap">
|
||||||
|
<SkyIcon condition={h.condition} isDay={h.isDay} size={18} />
|
||||||
|
{dayName(h.time, timeZone)} {formatHour(h.time, timeZone)}
|
||||||
|
</td>
|
||||||
|
<td className={`${cell} text-gold-bright`}>{formatTemp(h.tempC, units)}</td>
|
||||||
|
<td className={cell}>{h.feelsLikeC !== null ? formatTemp(h.feelsLikeC, units) : "–"}</td>
|
||||||
|
<td className={cell}>{h.precipChance !== null ? `${h.precipChance}%` : "–"}</td>
|
||||||
|
<td className={cell}>{formatWind(h.windKph, units)}{h.windDeg !== null ? ` ${compass(h.windDeg)}` : ""}</td>
|
||||||
|
<td className={cell}>{h.humidity !== null ? `${h.humidity}%` : "–"}</td>
|
||||||
|
<td className={cell}>{h.uvIndex !== null ? Math.round(h.uvIndex) : "–"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/radar/RadarMap.tsx
Normal file
73
components/radar/RadarMap.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { CircleMarker, MapContainer, Pane, TileLayer, useMap } from "react-leaflet";
|
||||||
|
import type { RadarLayer } from "@/lib/radar/layers";
|
||||||
|
import type { RadarFrame } from "@/lib/weather/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Leaflet map. Browser-only (Leaflet touches `window`), so RadarPanel
|
||||||
|
* loads it with next/dynamic and `ssr: false`.
|
||||||
|
*
|
||||||
|
* Layers, bottom to top:
|
||||||
|
* 1. dark basemap without labels (CARTO)
|
||||||
|
* 2. the radar/weather overlay
|
||||||
|
* 3. place-name labels (CARTO), so towns stay readable over the radar
|
||||||
|
* 4. a gold dot on the chosen place
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASEMAP = "/api/tiles/carto-dark-nolabels/{z}/{x}/{y}?r={r}";
|
||||||
|
const LABELS = "/api/tiles/carto-dark-labels/{z}/{x}/{y}?r={r}";
|
||||||
|
const BASEMAP_ATTRIBUTION =
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors, © <a href="https://carto.com/attributions" target="_blank" rel="noopener">CARTO</a>';
|
||||||
|
|
||||||
|
// Leaflet writes these straight onto SVG attributes, which accept theme variables.
|
||||||
|
const MARKER_STYLE = { color: "var(--color-gold-bright)", weight: 2, fillColor: "var(--color-black)", fillOpacity: 1 };
|
||||||
|
|
||||||
|
/** Moves the map when the chosen place changes, keeping the current zoom. */
|
||||||
|
function FollowPlace({ lat, lon }: { lat: number; lon: number }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
map.setView([lat, lon], map.getZoom());
|
||||||
|
}, [lat, lon, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RadarMap({ lat, lon, layer, frames, frameIndex }: {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
layer: RadarLayer;
|
||||||
|
frames: RadarFrame[];
|
||||||
|
frameIndex: number;
|
||||||
|
}) {
|
||||||
|
// Only the visible frame and the next one are mounted. The next one loads
|
||||||
|
// invisibly, so stepping forward never flashes an empty map.
|
||||||
|
const nextIndex = (frameIndex + 1) % Math.max(frames.length, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer center={[lat, lon]} zoom={7} minZoom={3} maxZoom={12} className="h-full w-full" scrollWheelZoom={false}>
|
||||||
|
<FollowPlace lat={lat} lon={lon} />
|
||||||
|
<TileLayer url={BASEMAP} attribution={BASEMAP_ATTRIBUTION} />
|
||||||
|
|
||||||
|
{frames.map((frame, i) =>
|
||||||
|
i === frameIndex || i === nextIndex ? (
|
||||||
|
<TileLayer
|
||||||
|
key={frame.url}
|
||||||
|
url={frame.url}
|
||||||
|
opacity={i === frameIndex ? 0.75 : 0}
|
||||||
|
maxNativeZoom={layer.maxNativeZoom}
|
||||||
|
attribution={layer.attribution}
|
||||||
|
zIndex={10}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pane name="labels" style={{ zIndex: 450, pointerEvents: "none" }}>
|
||||||
|
<TileLayer url={LABELS} />
|
||||||
|
</Pane>
|
||||||
|
|
||||||
|
<CircleMarker center={[lat, lon]} radius={6} pathOptions={MARKER_STYLE} />
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
components/radar/RadarPanel.tsx
Normal file
135
components/radar/RadarPanel.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { formatClock } from "@/lib/display";
|
||||||
|
import { loadFrames, RADAR_LAYERS, type RadarLayerId } from "@/lib/radar/layers";
|
||||||
|
import { outlineButton, pixelLabel, sectionHeading } from "@/lib/styles";
|
||||||
|
import type { Place, RadarFrame } from "@/lib/weather/types";
|
||||||
|
import { Slot } from "../Slot";
|
||||||
|
import { ChoiceChips } from "../ui/ChoiceChips";
|
||||||
|
|
||||||
|
// Leaflet needs `window`, so the map only ever renders in the browser.
|
||||||
|
const RadarMap = dynamic(() => import("./RadarMap"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="grid h-full place-items-center font-pixel text-xs text-stone">Loading map…</div>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FRAME_MS = 700; // animation speed
|
||||||
|
const REFRESH_MS = 5 * 60_000; // fetch fresh frames every 5 minutes
|
||||||
|
|
||||||
|
/** The radar section: layer picker, animated map, and playback controls. */
|
||||||
|
export function RadarPanel({ place, timeZone, hasOpenWeather }: { place: Place; timeZone: string | null; hasOpenWeather: boolean }) {
|
||||||
|
// US places start on the sharper NEXRAD radar; everywhere else on RainViewer.
|
||||||
|
const [layerId, setLayerId] = useState<RadarLayerId>(place.country === "US" ? "nexrad" : "rainviewer");
|
||||||
|
const [frames, setFrames] = useState<RadarFrame[]>([]);
|
||||||
|
const [frameIndex, setFrameIndex] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Switch between US-only NEXRAD and global RainViewer when crossing borders.
|
||||||
|
useEffect(() => {
|
||||||
|
setLayerId((current) => {
|
||||||
|
if (place.country !== "US" && current === "nexrad") return "rainviewer";
|
||||||
|
if (place.country === "US" && current === "rainviewer") return "nexrad";
|
||||||
|
if (!hasOpenWeather && current.startsWith("owm-")) return place.country === "US" ? "nexrad" : "rainviewer";
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}, [place.country, hasOpenWeather]);
|
||||||
|
|
||||||
|
const layer = RADAR_LAYERS.find((l) => l.id === layerId)!;
|
||||||
|
const isAnimated = frames.length > 1;
|
||||||
|
|
||||||
|
// Load frames for the chosen layer, then keep them fresh.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const loaded = await loadFrames(layerId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setFrames(loaded);
|
||||||
|
setFrameIndex(loaded.length - 1); // start on the latest frame
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
const timer = setInterval(refresh, REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [layerId]);
|
||||||
|
|
||||||
|
// Autoplay unless the person prefers reduced motion.
|
||||||
|
useEffect(() => {
|
||||||
|
setPlaying(!window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Step through frames while playing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || !isAnimated) return;
|
||||||
|
const timer = setInterval(() => setFrameIndex((i) => (i + 1) % frames.length), FRAME_MS);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [playing, isAnimated, frames.length]);
|
||||||
|
|
||||||
|
const frame = frames[frameIndex];
|
||||||
|
const isLatest = frameIndex === frames.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="radar-heading" className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 id="radar-heading" className={sectionHeading}>
|
||||||
|
Radar and maps
|
||||||
|
</h2>
|
||||||
|
<span className="h-px flex-1 bg-linear-to-r from-gold-dim/70 to-transparent" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChoiceChips
|
||||||
|
legend="Map layer"
|
||||||
|
size="sm"
|
||||||
|
selected={layerId}
|
||||||
|
onChange={setLayerId}
|
||||||
|
choices={RADAR_LAYERS.filter((l) => hasOpenWeather || !l.needsOpenWeather).map((l) => ({ value: l.id, label: l.label }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Height goes on the outer frame: the inner one is already h-full, and two
|
||||||
|
height utilities on one element would fight over which wins. */}
|
||||||
|
<Slot className="h-[360px] [--cut:10px] sm:h-[460px]" innerClassName="">
|
||||||
|
|
||||||
|
<RadarMap lat={place.lat} lon={place.lon} layer={layer} frames={frames} frameIndex={frameIndex} />
|
||||||
|
</Slot>
|
||||||
|
|
||||||
|
{isAnimated && frame && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" className={outlineButton} onClick={() => setPlaying((p) => !p)} aria-pressed={playing}>
|
||||||
|
{playing ? "Pause" : "Play"}
|
||||||
|
</button>
|
||||||
|
<label className="sr-only" htmlFor="radar-frame">
|
||||||
|
Radar frame
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="radar-frame"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={frames.length - 1}
|
||||||
|
value={frameIndex}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPlaying(false);
|
||||||
|
setFrameIndex(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 accent-gold"
|
||||||
|
/>
|
||||||
|
<span className="w-[4.5rem] text-right font-pixel text-xs text-ink tabular-nums" aria-live="off">
|
||||||
|
{isLatest ? "Latest" : formatClock(frame.time, timeZone)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={pixelLabel}>{error ?? layer.hint}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
components/ui/ChoiceChips.tsx
Normal file
74
components/ui/ChoiceChips.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
export interface Choice<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
note?: string; // small extra text, e.g. "14d" or "No key"
|
||||||
|
noteTone?: "muted" | "subtle" | "warning";
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of single-choice chips — used for providers, forecast views, map layers.
|
||||||
|
*
|
||||||
|
* Each chip is a real radio input, visually hidden; the label is styled from
|
||||||
|
* the input's state with Tailwind's `has-*` variants. Arrow keys move between
|
||||||
|
* chips for free because they're a native radio group.
|
||||||
|
*/
|
||||||
|
export function ChoiceChips<T extends string>({ legend, choices, selected, onChange, size = "md" }: {
|
||||||
|
legend: string;
|
||||||
|
choices: Choice<T>[];
|
||||||
|
selected: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}) {
|
||||||
|
const name = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
// min-w-0: fieldsets default to min-content width, which would widen the page on phones
|
||||||
|
<fieldset className="m-0 flex min-w-0 gap-2 overflow-x-auto border-0 p-0 pb-1 [scrollbar-width:none]">
|
||||||
|
<legend className="sr-only">{legend}</legend>
|
||||||
|
|
||||||
|
{choices.map((choice) => (
|
||||||
|
<label
|
||||||
|
key={choice.value}
|
||||||
|
className={[
|
||||||
|
"relative flex flex-none cursor-pointer items-center gap-2 border border-gold-dim bg-panel/60 font-pixel text-xs text-stone transition-colors duration-150",
|
||||||
|
size === "md" ? "min-h-[38px] px-3.5" : "min-h-8 px-2.5",
|
||||||
|
"hover:not-has-disabled:not-has-checked:border-gold/70 hover:not-has-disabled:not-has-checked:text-ink",
|
||||||
|
// Selected: gold border and text, plus a small diamond in front
|
||||||
|
"has-checked:border-gold has-checked:bg-gold/12 has-checked:text-gold-bright",
|
||||||
|
"has-checked:before:size-1.5 has-checked:before:rotate-45 has-checked:before:bg-gold-bright has-checked:before:content-['']",
|
||||||
|
// Keyboard focus lands on the hidden input, so show it on the label
|
||||||
|
"has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-teal",
|
||||||
|
"has-disabled:cursor-not-allowed has-disabled:opacity-50",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
className="sr-only"
|
||||||
|
value={choice.value}
|
||||||
|
checked={choice.value === selected}
|
||||||
|
disabled={choice.disabled}
|
||||||
|
onChange={() => onChange(choice.value)}
|
||||||
|
/>
|
||||||
|
{choice.label}
|
||||||
|
{choice.note && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
choice.noteTone === "warning"
|
||||||
|
? "text-heart"
|
||||||
|
: choice.noteTone === "subtle"
|
||||||
|
? "text-[0.65rem] text-stone/80"
|
||||||
|
: "text-teal/90"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{choice.note}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
lib/client-api.ts
Normal file
25
lib/client-api.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Place, ProviderId, ProviderInfo, WeatherReport } from "./weather/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-side calls to our own API routes.
|
||||||
|
* Each throws an Error carrying the server's message, ready to show in the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
|
||||||
|
const response = await fetch(url, { signal });
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body.error ?? `Request failed (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchProviders = () => getJson<ProviderInfo[]>("/api/providers");
|
||||||
|
|
||||||
|
export const searchPlaces = (query: string, signal?: AbortSignal) =>
|
||||||
|
getJson<Place[]>(`/api/geocode?q=${encodeURIComponent(query)}`, signal);
|
||||||
|
|
||||||
|
export const namePlace = (lat: number, lon: number) => getJson<Place>(`/api/geocode?lat=${lat}&lon=${lon}`);
|
||||||
|
|
||||||
|
export const fetchWeather = (provider: ProviderId, place: Place, signal?: AbortSignal) =>
|
||||||
|
getJson<WeatherReport>(`/api/weather?provider=${provider}&lat=${place.lat}&lon=${place.lon}`, signal);
|
||||||
186
lib/display.ts
Normal file
186
lib/display.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import type { CurrentWeather, HourlyForecast, Place } from "./weather/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display helpers: unit conversion, time formatting, labels and small calculations.
|
||||||
|
* The API always speaks metric and UTC; everything here converts for the screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Units = "imperial" | "metric";
|
||||||
|
|
||||||
|
// ---------- Units ----------
|
||||||
|
|
||||||
|
export function formatTemp(celsius: number, units: Units): string {
|
||||||
|
const value = units === "imperial" ? celsius * (9 / 5) + 32 : celsius;
|
||||||
|
return `${Math.round(value)}°`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Temperature as a plain number in the chosen unit (for charts). */
|
||||||
|
export function tempValue(celsius: number, units: Units): number {
|
||||||
|
return units === "imperial" ? celsius * (9 / 5) + 32 : celsius;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWind(kph: number, units: Units): string {
|
||||||
|
return units === "imperial" ? `${Math.round(kph / 1.609)} mph` : `${Math.round(kph)} km/h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPressure(hpa: number, units: Units): string {
|
||||||
|
return units === "imperial" ? `${(hpa * 0.02953).toFixed(2)} inHg` : `${Math.round(hpa)} hPa`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDistance(km: number, units: Units): string {
|
||||||
|
const value = units === "imperial" ? km / 1.609 : km;
|
||||||
|
const unit = units === "imperial" ? "mi" : "km";
|
||||||
|
return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrecip(mm: number, units: Units): string {
|
||||||
|
return units === "imperial" ? `${(mm / 25.4).toFixed(2)} in` : `${mm.toFixed(1)} mm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
|
||||||
|
|
||||||
|
/** 225° → "SW". Wind direction is where the wind comes FROM. */
|
||||||
|
export function compass(degrees: number): string {
|
||||||
|
return COMPASS[Math.round(degrees / 22.5) % 16];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Time (always shown in the location's time zone) ----------
|
||||||
|
|
||||||
|
/** Builds a formatter for the location's zone, falling back to the browser's. */
|
||||||
|
function inZone(timeZone: string | null, options: Intl.DateTimeFormatOptions) {
|
||||||
|
return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone ?? undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "7:31 PM" */
|
||||||
|
export function formatClock(iso: string, timeZone: string | null): string {
|
||||||
|
return inZone(timeZone, { hour: "numeric", minute: "2-digit" }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "7 PM" */
|
||||||
|
export function formatHour(iso: string, timeZone: string | null): string {
|
||||||
|
return inZone(timeZone, { hour: "numeric" }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Thu" */
|
||||||
|
export function formatWeekday(iso: string, timeZone: string | null): string {
|
||||||
|
return inZone(timeZone, { weekday: "short" }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Oct 23" */
|
||||||
|
export function formatShortDate(iso: string, timeZone: string | null): string {
|
||||||
|
return inZone(timeZone, { month: "short", day: "numeric" }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-10-23" — the local calendar day, for grouping and comparisons. */
|
||||||
|
export function localDayKey(iso: string, timeZone: string | null): string {
|
||||||
|
// Canadian English happens to format dates as YYYY-MM-DD.
|
||||||
|
return new Intl.DateTimeFormat("en-CA", { timeZone: timeZone ?? undefined }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today", "Tomorrow", or "Thu" */
|
||||||
|
export function dayName(iso: string, timeZone: string | null): string {
|
||||||
|
const today = localDayKey(new Date().toISOString(), timeZone);
|
||||||
|
const tomorrow = localDayKey(new Date(Date.now() + 86_400_000).toISOString(), timeZone);
|
||||||
|
const day = localDayKey(iso, timeZone);
|
||||||
|
if (day === today) return "Today";
|
||||||
|
if (day === tomorrow) return "Tomorrow";
|
||||||
|
return formatWeekday(iso, timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "10 h 41 m" between two instants. */
|
||||||
|
export function formatDuration(fromIso: string, toIso: string): string {
|
||||||
|
const minutes = Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60_000);
|
||||||
|
return `${Math.floor(minutes / 60)} h ${minutes % 60} m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeLabel(place: Place): string {
|
||||||
|
const extra = place.region ?? place.country;
|
||||||
|
return extra ? `${place.name}, ${extra}` : place.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "47.61°N · 122.33°W" */
|
||||||
|
export function formatCoords(lat: number, lon: number, decimals = 2): string {
|
||||||
|
const ns = lat >= 0 ? "N" : "S";
|
||||||
|
const ew = lon >= 0 ? "E" : "W";
|
||||||
|
return `${Math.abs(lat).toFixed(decimals)}°${ns} · ${Math.abs(lon).toFixed(decimals)}°${ew}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Labels for indexes ----------
|
||||||
|
|
||||||
|
/** UV index categories from the WHO / US EPA. */
|
||||||
|
export function uvLabel(uv: number): string {
|
||||||
|
if (uv < 3) return "Low";
|
||||||
|
if (uv < 6) return "Moderate";
|
||||||
|
if (uv < 8) return "High";
|
||||||
|
if (uv < 11) return "Very high";
|
||||||
|
return "Extreme";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** US EPA AQI categories. */
|
||||||
|
export function aqiLabel(aqi: number): string {
|
||||||
|
if (aqi <= 50) return "Good";
|
||||||
|
if (aqi <= 100) return "Moderate";
|
||||||
|
if (aqi <= 150) return "Unhealthy for sensitive groups";
|
||||||
|
if (aqi <= 200) return "Unhealthy";
|
||||||
|
if (aqi <= 300) return "Very unhealthy";
|
||||||
|
return "Hazardous";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Moon ----------
|
||||||
|
|
||||||
|
const SYNODIC_MONTH_DAYS = 29.530588853;
|
||||||
|
const KNOWN_NEW_MOON = Date.UTC(2000, 0, 6, 18, 14); // 6 Jan 2000, 18:14 UTC
|
||||||
|
|
||||||
|
const MOON_NAMES = [
|
||||||
|
"New moon", "Waxing crescent", "First quarter", "Waxing gibbous",
|
||||||
|
"Full moon", "Waning gibbous", "Last quarter", "Waning crescent",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moon phase from simple arithmetic — accurate to within about a day,
|
||||||
|
* which is plenty for "what does the moon look like tonight".
|
||||||
|
* `fraction`: 0 = new, 0.5 = full, back to 1 = new.
|
||||||
|
*/
|
||||||
|
export function moonPhase(date = new Date()): { fraction: number; name: string; illumination: number } {
|
||||||
|
const daysSince = (date.getTime() - KNOWN_NEW_MOON) / 86_400_000;
|
||||||
|
const fraction = (((daysSince / SYNODIC_MONTH_DAYS) % 1) + 1) % 1;
|
||||||
|
const name = MOON_NAMES[Math.round(fraction * 8) % 8];
|
||||||
|
const illumination = Math.round(((1 - Math.cos(fraction * 2 * Math.PI)) / 2) * 100);
|
||||||
|
return { fraction, name, illumination };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Small summaries ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One plain sentence about precipitation in the next 24 hours, e.g.
|
||||||
|
* "Rain likely around 9 PM (70%)." or "No precipitation expected in the next 24 hours."
|
||||||
|
*/
|
||||||
|
export function precipOutlook(hourly: HourlyForecast[], timeZone: string | null): string | null {
|
||||||
|
const next24 = hourly.filter((h) => Date.parse(h.time) < Date.now() + 24 * 3_600_000);
|
||||||
|
if (next24.length === 0 || next24.every((h) => h.precipChance === null)) return null;
|
||||||
|
|
||||||
|
const likely = next24.find((h) => (h.precipChance ?? 0) >= 50);
|
||||||
|
if (!likely) return "No precipitation expected in the next 24 hours.";
|
||||||
|
|
||||||
|
const kind = likely.condition === "snow" ? "Snow" : likely.condition === "storm" ? "Storms" : "Rain";
|
||||||
|
const isNow = likely === next24[0];
|
||||||
|
const when = isNow ? "now" : `around ${formatHour(likely.time, timeZone)}`;
|
||||||
|
return `${kind} likely ${when} (${likely.precipChance}%).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comfort, out of 5 hearts (in half-heart steps).
|
||||||
|
* Start full, then lose hearts for being far from 21 °C, for wind, and for weather.
|
||||||
|
* It's a playful summary, not science — tweak the numbers to taste.
|
||||||
|
*/
|
||||||
|
export function comfortHearts(current: CurrentWeather): number {
|
||||||
|
let hearts = 5;
|
||||||
|
|
||||||
|
hearts -= Math.abs(current.feelsLikeC - 21) / 6; // −1 heart per 6 °C away from ideal
|
||||||
|
if (current.windKph > 25) hearts -= 1;
|
||||||
|
if (current.condition === "rain" || current.condition === "snow") hearts -= 1;
|
||||||
|
if (current.condition === "storm") hearts -= 2;
|
||||||
|
|
||||||
|
const clamped = Math.min(5, Math.max(0, hearts));
|
||||||
|
return Math.round(clamped * 2) / 2;
|
||||||
|
}
|
||||||
120
lib/pixel-art.ts
Normal file
120
lib/pixel-art.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Hand-drawn pixel art, stored as text so it's easy to read and edit.
|
||||||
|
*
|
||||||
|
* Each string is one row. Each character is one pixel:
|
||||||
|
* . transparent G gold W parchment white
|
||||||
|
* D stone grey T spirit teal R heart red
|
||||||
|
* E empty-heart outline
|
||||||
|
*/
|
||||||
|
|
||||||
|
// These are the Tailwind theme colors from app/globals.css.
|
||||||
|
export const PALETTE: Record<string, string> = {
|
||||||
|
G: "var(--color-gold-bright)",
|
||||||
|
W: "var(--color-ink)",
|
||||||
|
D: "var(--color-stone)",
|
||||||
|
T: "var(--color-teal)",
|
||||||
|
R: "var(--color-heart)",
|
||||||
|
E: "var(--color-heart-empty)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUN = [
|
||||||
|
".....G.....",
|
||||||
|
".G.......G.",
|
||||||
|
"...GGGGG...",
|
||||||
|
"..GGGGGGG..",
|
||||||
|
"..GGGGGGG..",
|
||||||
|
"G.GGGGGGG.G",
|
||||||
|
"..GGGGGGG..",
|
||||||
|
"..GGGGGGG..",
|
||||||
|
"...GGGGG...",
|
||||||
|
".G.......G.",
|
||||||
|
".....G.....",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOON = [
|
||||||
|
"...........",
|
||||||
|
"....WWWW...",
|
||||||
|
"..WWWW.....",
|
||||||
|
"..WWW......",
|
||||||
|
".WWW.......",
|
||||||
|
".WWW.......",
|
||||||
|
".WWW.......",
|
||||||
|
"..WWW......",
|
||||||
|
"..WWWW.....",
|
||||||
|
"....WWWW...",
|
||||||
|
"...........",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CLOUD = [
|
||||||
|
"...........",
|
||||||
|
"...........",
|
||||||
|
"....WWW....",
|
||||||
|
"...WWWWW...",
|
||||||
|
".WWWWWWWW..",
|
||||||
|
"WWWWWWWWWW.",
|
||||||
|
"WWWWWWWWWWW",
|
||||||
|
".WWWWWWWWW.",
|
||||||
|
"...........",
|
||||||
|
"...........",
|
||||||
|
"...........",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RAIN = [
|
||||||
|
"....DDD....",
|
||||||
|
"...DDDDD...",
|
||||||
|
".DDDDDDDD..",
|
||||||
|
"DDDDDDDDDD.",
|
||||||
|
"DDDDDDDDDDD",
|
||||||
|
".DDDDDDDDD.",
|
||||||
|
"...........",
|
||||||
|
".T...T...T.",
|
||||||
|
"...T...T...",
|
||||||
|
".T...T...T.",
|
||||||
|
"...........",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SNOW = [
|
||||||
|
"....DDD....",
|
||||||
|
"...DDDDD...",
|
||||||
|
".DDDDDDDD..",
|
||||||
|
"DDDDDDDDDD.",
|
||||||
|
"DDDDDDDDDDD",
|
||||||
|
".DDDDDDDDD.",
|
||||||
|
"...........",
|
||||||
|
".W...W...W.",
|
||||||
|
"...........",
|
||||||
|
"...W...W...",
|
||||||
|
"...........",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STORM = [
|
||||||
|
"....DDD....",
|
||||||
|
"...DDDDD...",
|
||||||
|
".DDDDDDDD..",
|
||||||
|
"DDDDDDDDDD.",
|
||||||
|
"DDDDDDDDDDD",
|
||||||
|
".DDDGGDDDD.",
|
||||||
|
"....GG.....",
|
||||||
|
"...GG......",
|
||||||
|
"..GGGGG....",
|
||||||
|
"....GG.....",
|
||||||
|
"...G.......",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FOG = [
|
||||||
|
"...........",
|
||||||
|
".DDDDDDDD..",
|
||||||
|
"...........",
|
||||||
|
"..DDDDDDDDD",
|
||||||
|
"...........",
|
||||||
|
"DDDDDDDD...",
|
||||||
|
"...........",
|
||||||
|
".DDDDDDDDD.",
|
||||||
|
"...........",
|
||||||
|
"...DDDDDD..",
|
||||||
|
"...........",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HEART_FULL = [".RR.RR.", "RRRRRRR", "RRRRRRR", ".RRRRR.", "..RRR..", "...R..."];
|
||||||
|
export const HEART_HALF = [".RR.EE.", "RRRREEE", "RRRREEE", ".RRREE.", "..RRE..", "...R..."];
|
||||||
|
export const HEART_EMPTY = [".EE.EE.", "EEEEEEE", "EEEEEEE", ".EEEEE.", "..EEE..", "...E..."];
|
||||||
86
lib/radar/layers.ts
Normal file
86
lib/radar/layers.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { RadarFrame } from "../weather/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every map overlay the radar panel can show, and how to get its frames.
|
||||||
|
*
|
||||||
|
* RainViewer — worldwide radar, last ~2 hours in 10-minute steps.
|
||||||
|
* Since Jan 2026: past frames only, max zoom 7, one color scheme,
|
||||||
|
* personal/educational use only.
|
||||||
|
* NEXRAD — US-only NWS radar via Iowa State's mesonet; much sharper,
|
||||||
|
* last 50 minutes in 5-minute steps. No key.
|
||||||
|
* OpenWeather — model maps (precipitation, clouds, temperature, wind, pressure).
|
||||||
|
* Needs OPENWEATHER_API_KEY; tiles go through /api/tiles so the key stays secret.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RadarLayerId = "rainviewer" | "nexrad" | "owm-precip" | "owm-clouds" | "owm-temp" | "owm-wind" | "owm-pressure";
|
||||||
|
|
||||||
|
export interface RadarLayer {
|
||||||
|
id: RadarLayerId;
|
||||||
|
label: string;
|
||||||
|
hint: string; // shown under the map
|
||||||
|
maxNativeZoom: number; // beyond this, tiles are stretched rather than fetched
|
||||||
|
needsOpenWeather: boolean;
|
||||||
|
attribution: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWM_ATTRIBUTION =
|
||||||
|
'Weather data provided by <a href="https://openweathermap.org/" target="_blank" rel="noopener">OpenWeather</a> (<a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>)';
|
||||||
|
|
||||||
|
export const RADAR_LAYERS: RadarLayer[] = [
|
||||||
|
{
|
||||||
|
id: "rainviewer",
|
||||||
|
label: "Radar",
|
||||||
|
hint: "Worldwide radar, past 2 hours. Detail stops at regional zoom.",
|
||||||
|
maxNativeZoom: 7,
|
||||||
|
needsOpenWeather: false,
|
||||||
|
attribution: 'Weather data by <a href="https://www.rainviewer.com/" target="_blank" rel="noopener">RainViewer</a>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nexrad",
|
||||||
|
label: "US radar",
|
||||||
|
hint: "NWS NEXRAD radar, past 50 minutes. United States only, high detail.",
|
||||||
|
maxNativeZoom: 12,
|
||||||
|
needsOpenWeather: false,
|
||||||
|
attribution:
|
||||||
|
'NEXRAD via <a href="https://mesonet.agron.iastate.edu/" target="_blank" rel="noopener">Iowa Environmental Mesonet (Iowa State Univ.)</a>',
|
||||||
|
},
|
||||||
|
{ id: "owm-precip", label: "Precipitation", hint: "Modelled precipitation right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
|
||||||
|
{ id: "owm-clouds", label: "Clouds", hint: "Modelled cloud cover right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
|
||||||
|
{ id: "owm-temp", label: "Temperature", hint: "Modelled air temperature right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
|
||||||
|
{ id: "owm-wind", label: "Wind", hint: "Modelled wind speed right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
|
||||||
|
{ id: "owm-pressure", label: "Pressure", hint: "Modelled sea-level pressure right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Our layer ids → OpenWeather's tile layer names. Also the allow-list for /api/tiles. */
|
||||||
|
export const OWM_TILE_LAYERS: Record<string, string> = {
|
||||||
|
"owm-precip": "precipitation_new",
|
||||||
|
"owm-clouds": "clouds_new",
|
||||||
|
"owm-temp": "temp_new",
|
||||||
|
"owm-wind": "wind_new",
|
||||||
|
"owm-pressure": "pressure_new",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** NEXRAD frames are fixed offsets from "now": 50, 45 … 5 minutes ago, then the latest. */
|
||||||
|
function nexradFrames(): RadarFrame[] {
|
||||||
|
const fiveMinutes = 5 * 60_000;
|
||||||
|
const latest = Math.floor(Date.now() / fiveMinutes) * fiveMinutes;
|
||||||
|
const base = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913";
|
||||||
|
|
||||||
|
return [50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0].map((minutesAgo) => ({
|
||||||
|
time: new Date(latest - minutesAgo * 60_000).toISOString(),
|
||||||
|
url: minutesAgo === 0 ? `${base}/{z}/{x}/{y}.png` : `${base}-m${String(minutesAgo).padStart(2, "0")}m/{z}/{x}/{y}.png`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the frames for a layer. Single-frame layers return a list of one. */
|
||||||
|
export async function loadFrames(id: RadarLayerId): Promise<RadarFrame[]> {
|
||||||
|
if (id === "nexrad") return nexradFrames();
|
||||||
|
|
||||||
|
if (id === "rainviewer") {
|
||||||
|
const response = await fetch("/api/radar");
|
||||||
|
if (!response.ok) throw new Error("Radar frames are unavailable right now");
|
||||||
|
return (await response.json()) as RadarFrame[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ time: new Date().toISOString(), url: `/api/tiles/${id}/{z}/{x}/{y}` }];
|
||||||
|
}
|
||||||
15
lib/styles.ts
Normal file
15
lib/styles.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Tailwind class strings used by more than one component.
|
||||||
|
* Kept here so a style change happens in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Small pixel-font caption, e.g. "Feels like", "Comfort". */
|
||||||
|
export const pixelLabel = "font-pixel text-[0.7rem] tracking-[0.03em] text-stone";
|
||||||
|
|
||||||
|
/** Gold outlined button that fills in on hover. */
|
||||||
|
export const outlineButton =
|
||||||
|
"min-h-[46px] cursor-pointer whitespace-nowrap border border-gold bg-panel/70 px-2.5 font-pixel text-xs text-gold-bright transition-colors duration-150 " +
|
||||||
|
"hover:enabled:bg-gold hover:enabled:text-black disabled:cursor-wait disabled:opacity-60 sm:px-3.5";
|
||||||
|
|
||||||
|
/** Gold Cinzel section title used above Conditions, Forecast, Radar, and Waypoints. */
|
||||||
|
export const sectionHeading = "font-display text-base font-medium tracking-[0.12em] uppercase text-gold sm:text-lg";
|
||||||
18
lib/weather/describe.ts
Normal file
18
lib/weather/describe.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { AlertSeverity, Condition } from "./types";
|
||||||
|
|
||||||
|
/** Fallback wording for providers that only give us a numeric code. */
|
||||||
|
export const CONDITION_LABELS: Record<Condition, string> = {
|
||||||
|
clear: "clear skies",
|
||||||
|
clouds: "cloudy",
|
||||||
|
rain: "rain",
|
||||||
|
storm: "thunderstorm",
|
||||||
|
snow: "snow",
|
||||||
|
fog: "fog",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Maps any provider's severity word ("Severe", "severe", "Moderate"…) onto ours. */
|
||||||
|
export function toSeverity(word: string | null | undefined): AlertSeverity {
|
||||||
|
const normalized = (word ?? "").toLowerCase();
|
||||||
|
const known: AlertSeverity[] = ["extreme", "severe", "moderate", "minor"];
|
||||||
|
return known.find((s) => normalized.includes(s)) ?? "unknown";
|
||||||
|
}
|
||||||
50
lib/weather/extras/air-quality.ts
Normal file
50
lib/weather/extras/air-quality.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { fetchJson, maybe } from "../http";
|
||||||
|
import type { AirQuality } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Air quality from Open-Meteo's free air-quality API (CAMS data), used for
|
||||||
|
* every provider so the AQI always means the same thing.
|
||||||
|
* Docs: https://open-meteo.com/en/docs/air-quality-api
|
||||||
|
*
|
||||||
|
* The same call also tells us the location's IANA time zone, which some
|
||||||
|
* providers (Tomorrow.io, OpenWeather free) don't give us.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AirQualityResponse {
|
||||||
|
timezone: string;
|
||||||
|
current: {
|
||||||
|
us_aqi: number | null;
|
||||||
|
pm2_5: number | null;
|
||||||
|
pm10: number | null;
|
||||||
|
ozone: number | null;
|
||||||
|
nitrogen_dioxide: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalContext {
|
||||||
|
timeZone: string;
|
||||||
|
airQuality: AirQuality | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocalContext(lat: number, lon: number): Promise<LocalContext> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: String(lat),
|
||||||
|
longitude: String(lon),
|
||||||
|
current: "us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide",
|
||||||
|
timezone: "auto",
|
||||||
|
});
|
||||||
|
const data = await fetchJson<AirQualityResponse>(
|
||||||
|
`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`,
|
||||||
|
"Open-Meteo air quality",
|
||||||
|
{ cacheSeconds: 1800 }, // air quality updates hourly at most
|
||||||
|
);
|
||||||
|
|
||||||
|
const c = data.current;
|
||||||
|
return {
|
||||||
|
timeZone: data.timezone,
|
||||||
|
airQuality:
|
||||||
|
c.us_aqi === null
|
||||||
|
? null
|
||||||
|
: { usAqi: Math.round(c.us_aqi), pm25: maybe(c.pm2_5), pm10: maybe(c.pm10), ozone: maybe(c.ozone), no2: maybe(c.nitrogen_dioxide) },
|
||||||
|
};
|
||||||
|
}
|
||||||
75
lib/weather/extras/nws-alerts.ts
Normal file
75
lib/weather/extras/nws-alerts.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { toSeverity } from "../describe";
|
||||||
|
import { fetchJson } from "../http";
|
||||||
|
import type { WeatherAlert } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active US weather alerts from the National Weather Service (free, no key).
|
||||||
|
* Docs: https://www.weather.gov/documentation/services-web-api
|
||||||
|
*
|
||||||
|
* NWS asks every app to identify itself with a User-Agent containing contact
|
||||||
|
* info, so set NWS_CONTACT in .env.local (an email or website).
|
||||||
|
* Outside the US this returns nothing, which is fine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface NwsResponse {
|
||||||
|
features: {
|
||||||
|
properties: {
|
||||||
|
event: string;
|
||||||
|
headline: string | null;
|
||||||
|
severity: string;
|
||||||
|
senderName: string;
|
||||||
|
onset: string | null;
|
||||||
|
effective: string;
|
||||||
|
ends: string | null;
|
||||||
|
expires: string;
|
||||||
|
description: string;
|
||||||
|
instruction: string | null;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rough boxes around US states and territories — skips pointless calls elsewhere. */
|
||||||
|
function mightBeInUS(lat: number, lon: number): boolean {
|
||||||
|
const mainland = lat > 24 && lat < 50 && lon > -125 && lon < -66;
|
||||||
|
const alaska = lat > 51 && lat < 72 && (lon < -129 || lon > 172);
|
||||||
|
const hawaii = lat > 18 && lat < 23 && lon > -161 && lon < -154;
|
||||||
|
const caribbean = lat > 17 && lat < 19 && lon > -68 && lon < -64; // Puerto Rico, USVI
|
||||||
|
const pacific = lat > 13 && lat < 21 && lon > 144 && lon < 146; // Guam, Northern Marianas
|
||||||
|
return mainland || alaska || hawaii || caribbean || pacific;
|
||||||
|
}
|
||||||
|
|
||||||
|
let warnedMissingContact = false;
|
||||||
|
|
||||||
|
function buildNwsUserAgent(): string {
|
||||||
|
const explicit = process.env.NWS_CONTACT?.trim();
|
||||||
|
if (explicit) return `WayfarerWeather/1.0 (${explicit})`;
|
||||||
|
|
||||||
|
const deployedHost = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || process.env.VERCEL_URL?.trim();
|
||||||
|
if (deployedHost) return `WayfarerWeather/1.0 (https://${deployedHost})`;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production" && !warnedMissingContact) {
|
||||||
|
warnedMissingContact = true;
|
||||||
|
console.warn("[Wayfarer] Set NWS_CONTACT (email or site URL) so api.weather.gov requests include contact details.");
|
||||||
|
}
|
||||||
|
return "WayfarerWeather/1.0 (local-dev)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNwsAlerts(lat: number, lon: number): Promise<WeatherAlert[]> {
|
||||||
|
if (!mightBeInUS(lat, lon)) return [];
|
||||||
|
|
||||||
|
const point = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||||
|
const data = await fetchJson<NwsResponse>(`https://api.weather.gov/alerts/active?point=${point}`, "National Weather Service", {
|
||||||
|
cacheSeconds: 300, // alerts matter; refresh more often
|
||||||
|
headers: { "User-Agent": buildNwsUserAgent(), Accept: "application/geo+json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.features.map(({ properties: p }) => ({
|
||||||
|
title: p.headline ?? p.event,
|
||||||
|
severity: toSeverity(p.severity),
|
||||||
|
source: p.senderName,
|
||||||
|
starts: p.onset ?? p.effective,
|
||||||
|
ends: p.ends ?? p.expires,
|
||||||
|
description: p.description,
|
||||||
|
instruction: p.instruction,
|
||||||
|
}));
|
||||||
|
}
|
||||||
60
lib/weather/geocode.ts
Normal file
60
lib/weather/geocode.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { fetchJson } from "./http";
|
||||||
|
import type { Place } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a city name into coordinates (and coordinates back into a name).
|
||||||
|
*
|
||||||
|
* OpenWeather's geocoder is used when its key is set; otherwise we fall back
|
||||||
|
* to Open-Meteo's free geocoder so search still works with zero keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface OwmPlace {
|
||||||
|
name: string;
|
||||||
|
state?: string;
|
||||||
|
country: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenMeteoGeocodeResponse {
|
||||||
|
results?: { name: string; admin1?: string; country_code: string; latitude: number; longitude: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWM_GEO = "https://api.openweathermap.org/geo/1.0";
|
||||||
|
|
||||||
|
function fromOwm(place: OwmPlace): Place {
|
||||||
|
return { name: place.name, region: place.state, country: place.country, lat: place.lat, lon: place.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPlaces(query: string): Promise<Place[]> {
|
||||||
|
const key = process.env.OPENWEATHER_API_KEY?.trim();
|
||||||
|
const q = encodeURIComponent(query.slice(0, 120));
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
const places = await fetchJson<OwmPlace[]>(`${OWM_GEO}/direct?q=${q}&limit=5&appid=${key}`, "OpenWeather");
|
||||||
|
return places.map(fromOwm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchJson<OpenMeteoGeocodeResponse>(
|
||||||
|
`https://geocoding-api.open-meteo.com/v1/search?name=${q}&count=5`,
|
||||||
|
"Open-Meteo",
|
||||||
|
);
|
||||||
|
return (data.results ?? []).map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
region: r.admin1,
|
||||||
|
country: r.country_code,
|
||||||
|
lat: r.latitude,
|
||||||
|
lon: r.longitude,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used by "Use my location". Without an OpenWeather key we can't name the spot. */
|
||||||
|
export async function reversePlace(lat: number, lon: number): Promise<Place> {
|
||||||
|
const fallback: Place = { name: "Your location", lat, lon };
|
||||||
|
const key = process.env.OPENWEATHER_API_KEY?.trim();
|
||||||
|
if (!key) return fallback;
|
||||||
|
|
||||||
|
const places = await fetchJson<OwmPlace[]>(`${OWM_GEO}/reverse?lat=${lat}&lon=${lon}&limit=1&appid=${key}`, "OpenWeather");
|
||||||
|
// Keep the user's exact coordinates rather than the city centre's.
|
||||||
|
return places[0] ? { ...fromOwm(places[0]), lat, lon } : fallback;
|
||||||
|
}
|
||||||
93
lib/weather/http.ts
Normal file
93
lib/weather/http.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* Small server-side helpers shared by every provider adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** An error that knows which HTTP status the API route should answer with. */
|
||||||
|
export class ProviderError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number = 502,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ProviderError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads an API key from the environment, or fails with a clear message. */
|
||||||
|
export function readKey(envKey: string): string {
|
||||||
|
const key = process.env[envKey]?.trim();
|
||||||
|
if (!key) {
|
||||||
|
throw new ProviderError(`${envKey} is not configured`, 500);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchOptions {
|
||||||
|
/** Seconds to cache the response. Weather barely changes in 10 minutes. */
|
||||||
|
cacheSeconds?: number;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches JSON with caching (default 10 minutes) to protect free-tier quotas.
|
||||||
|
* The URL often contains an API key, so it is never logged or echoed back.
|
||||||
|
*/
|
||||||
|
export async function fetchJson<T>(url: string, providerLabel: string, options: FetchOptions = {}): Promise<T> {
|
||||||
|
const { cacheSeconds = 600, headers } = options;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { headers, next: { revalidate: cacheSeconds } });
|
||||||
|
} catch {
|
||||||
|
// DNS failure, offline, firewall… the request never got an answer.
|
||||||
|
throw new ProviderError(`Couldn't reach ${providerLabel}. Check your connection and try again.`, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new ProviderError(`${providerLabel} rejected the API key`, 401);
|
||||||
|
}
|
||||||
|
if (response.status === 429) {
|
||||||
|
throw new ProviderError(`${providerLabel} rate limit reached. Try again shortly.`, 429);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ProviderError(`${providerLabel} responded with HTTP ${response.status}`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Number and time helpers ----------
|
||||||
|
|
||||||
|
/** Rounds to one decimal place so the JSON stays tidy. */
|
||||||
|
export function round1(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as round1, but passes null/undefined/NaN through as null. */
|
||||||
|
export function maybe(value: number | null | undefined): number | null {
|
||||||
|
return value === null || value === undefined || !Number.isFinite(value) ? null : round1(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres per second → km/h. */
|
||||||
|
export function msToKph(ms: number): number {
|
||||||
|
return round1(ms * 3.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeMsToKph(ms: number | null | undefined): number | null {
|
||||||
|
return ms === null || ms === undefined ? null : msToKph(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unix seconds → ISO instant. */
|
||||||
|
export function fromUnix(seconds: number): string {
|
||||||
|
return new Date(seconds * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the first `hours` of an hourly list, starting from the current hour. */
|
||||||
|
export function nextHours<T extends { time: string }>(items: T[], hours: number): T[] {
|
||||||
|
const startOfThisHour = Date.now() - 60 * 60 * 1000;
|
||||||
|
return items.filter((item) => Date.parse(item.time) >= startOfThisHour).slice(0, hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app shows at most 7 days of hourly data and 14 days of daily data. */
|
||||||
|
export const MAX_HOURS = 168;
|
||||||
|
export const MAX_DAYS = 14;
|
||||||
45
lib/weather/providers/index.ts
Normal file
45
lib/weather/providers/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { ProviderId, ProviderInfo, WeatherProvider } from "../types";
|
||||||
|
import { openMeteo } from "./openmeteo";
|
||||||
|
import { openWeather } from "./openweather";
|
||||||
|
import { tomorrowIo } from "./tomorrow";
|
||||||
|
import { weatherApi } from "./weatherapi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider registry.
|
||||||
|
* To add a provider: write an adapter that satisfies WeatherProvider,
|
||||||
|
* add it here, and add its id to ProviderId in ../types.ts.
|
||||||
|
*/
|
||||||
|
export const PROVIDERS: Record<ProviderId, WeatherProvider> = {
|
||||||
|
openweather: openWeather,
|
||||||
|
weatherapi: weatherApi,
|
||||||
|
tomorrow: tomorrowIo,
|
||||||
|
openmeteo: openMeteo,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Order shown in the picker. OpenWeather first because it's the default. */
|
||||||
|
const DISPLAY_ORDER: ProviderId[] = ["openweather", "weatherapi", "tomorrow", "openmeteo"];
|
||||||
|
|
||||||
|
export function isProviderId(value: string): value is ProviderId {
|
||||||
|
return value in PROVIDERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A provider is ready if it needs no key, or its key is present. */
|
||||||
|
export function isReady(provider: WeatherProvider): boolean {
|
||||||
|
return provider.envKey === null || Boolean(process.env[provider.envKey]?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider list for the browser — readiness and limits only, never the keys.
|
||||||
|
* Ready sources lead so the picker starts with working options out of the box. */
|
||||||
|
export function listProviders(): ProviderInfo[] {
|
||||||
|
const all = DISPLAY_ORDER.map((id) => {
|
||||||
|
const provider = PROVIDERS[id];
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: provider.label,
|
||||||
|
ready: isReady(provider),
|
||||||
|
signupUrl: provider.signupUrl,
|
||||||
|
...provider.limits(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [...all.filter((p) => p.ready), ...all.filter((p) => !p.ready)];
|
||||||
|
}
|
||||||
134
lib/weather/providers/openmeteo.ts
Normal file
134
lib/weather/providers/openmeteo.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { CONDITION_LABELS } from "../describe";
|
||||||
|
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, round1 } from "../http";
|
||||||
|
import type { Condition, WeatherProvider } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open-Meteo — free, no key, and the deepest data: hourly and daily out to 16 days.
|
||||||
|
* Docs: https://open-meteo.com/en/docs
|
||||||
|
*
|
||||||
|
* We ask for `timeformat=unixtime` so every time comes back as a plain number.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HOURLY_VARS = [
|
||||||
|
"temperature_2m", "apparent_temperature", "precipitation_probability", "precipitation",
|
||||||
|
"weather_code", "wind_speed_10m", "wind_gusts_10m", "wind_direction_10m",
|
||||||
|
"relative_humidity_2m", "uv_index", "is_day",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DAILY_VARS = [
|
||||||
|
"weather_code", "temperature_2m_max", "temperature_2m_min", "precipitation_probability_max",
|
||||||
|
"precipitation_sum", "wind_speed_10m_max", "wind_gusts_10m_max", "wind_direction_10m_dominant",
|
||||||
|
"uv_index_max", "sunrise", "sunset",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CURRENT_VARS = [
|
||||||
|
"temperature_2m", "apparent_temperature", "relative_humidity_2m", "is_day", "precipitation",
|
||||||
|
"weather_code", "cloud_cover", "pressure_msl", "wind_speed_10m", "wind_direction_10m",
|
||||||
|
"wind_gusts_10m", "dew_point_2m", "visibility", "uv_index",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Each section is "variable name → value" (current) or "variable name → array of values".
|
||||||
|
type Row<Names extends readonly string[]> = Record<Names[number], number>;
|
||||||
|
type Columns<Names extends readonly string[]> = Record<Names[number], (number | null)[]> & { time: number[] };
|
||||||
|
|
||||||
|
interface OpenMeteoResponse {
|
||||||
|
timezone: string;
|
||||||
|
current: Row<typeof CURRENT_VARS> & { time: number };
|
||||||
|
hourly: Columns<typeof HOURLY_VARS>;
|
||||||
|
daily: Columns<typeof DAILY_VARS>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WMO weather codes, listed at the bottom of the Open-Meteo docs page. */
|
||||||
|
function toCondition(code: number | null): Condition {
|
||||||
|
if (code === null || code <= 1) return "clear";
|
||||||
|
if (code <= 3) return "clouds";
|
||||||
|
if (code === 45 || code === 48) return "fog";
|
||||||
|
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow";
|
||||||
|
if (code >= 95) return "storm";
|
||||||
|
return "rain"; // drizzle (51–57), rain (61–67), showers (80–82)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TWELVE_HOURS = 12 * 60 * 60;
|
||||||
|
|
||||||
|
export const openMeteo: WeatherProvider = {
|
||||||
|
id: "openmeteo",
|
||||||
|
label: "Open-Meteo",
|
||||||
|
envKey: null,
|
||||||
|
signupUrl: "https://open-meteo.com/",
|
||||||
|
limits: () => ({ forecastDays: 14, hourlyStepHours: 1 }),
|
||||||
|
|
||||||
|
async getForecast(lat, lon) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: String(lat),
|
||||||
|
longitude: String(lon),
|
||||||
|
current: CURRENT_VARS.join(","),
|
||||||
|
hourly: HOURLY_VARS.join(","),
|
||||||
|
daily: DAILY_VARS.join(","),
|
||||||
|
timezone: "auto",
|
||||||
|
timeformat: "unixtime",
|
||||||
|
forecast_days: String(MAX_DAYS),
|
||||||
|
});
|
||||||
|
const { current: c, hourly: h, daily: d, timezone } = await fetchJson<OpenMeteoResponse>(
|
||||||
|
`https://api.open-meteo.com/v1/forecast?${params}`,
|
||||||
|
"Open-Meteo",
|
||||||
|
);
|
||||||
|
|
||||||
|
const condition = toCondition(c.weather_code);
|
||||||
|
|
||||||
|
const hourly = h.time.map((t, i) => ({
|
||||||
|
time: fromUnix(t),
|
||||||
|
tempC: round1(h.temperature_2m[i] ?? 0),
|
||||||
|
feelsLikeC: maybe(h.apparent_temperature[i]),
|
||||||
|
condition: toCondition(h.weather_code[i]),
|
||||||
|
isDay: h.is_day[i] === 1,
|
||||||
|
precipChance: maybe(h.precipitation_probability[i]),
|
||||||
|
precipMm: maybe(h.precipitation[i]),
|
||||||
|
windKph: round1(h.wind_speed_10m[i] ?? 0),
|
||||||
|
gustKph: maybe(h.wind_gusts_10m[i]),
|
||||||
|
windDeg: maybe(h.wind_direction_10m[i]),
|
||||||
|
humidity: maybe(h.relative_humidity_2m[i]),
|
||||||
|
uvIndex: maybe(h.uv_index[i]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeZone: timezone,
|
||||||
|
hourlyStepHours: 1,
|
||||||
|
alerts: [], // Open-Meteo has no alerts; the API route adds NWS alerts for US locations
|
||||||
|
current: {
|
||||||
|
time: fromUnix(c.time),
|
||||||
|
tempC: round1(c.temperature_2m),
|
||||||
|
feelsLikeC: round1(c.apparent_temperature),
|
||||||
|
condition,
|
||||||
|
description: CONDITION_LABELS[condition],
|
||||||
|
isDay: c.is_day === 1,
|
||||||
|
humidity: c.relative_humidity_2m,
|
||||||
|
windKph: round1(c.wind_speed_10m),
|
||||||
|
gustKph: maybe(c.wind_gusts_10m),
|
||||||
|
windDeg: maybe(c.wind_direction_10m),
|
||||||
|
pressureHpa: maybe(c.pressure_msl),
|
||||||
|
dewPointC: maybe(c.dew_point_2m),
|
||||||
|
visibilityKm: c.visibility == null ? null : round1(c.visibility / 1000),
|
||||||
|
uvIndex: maybe(c.uv_index),
|
||||||
|
cloudCover: maybe(c.cloud_cover),
|
||||||
|
precipMm: maybe(c.precipitation),
|
||||||
|
},
|
||||||
|
hourly: nextHours(hourly, MAX_HOURS),
|
||||||
|
daily: d.time.map((t, i) => ({
|
||||||
|
time: fromUnix(t + TWELVE_HOURS), // daily times are local midnight; use midday
|
||||||
|
highC: round1(d.temperature_2m_max[i] ?? 0),
|
||||||
|
lowC: round1(d.temperature_2m_min[i] ?? 0),
|
||||||
|
condition: toCondition(d.weather_code[i]),
|
||||||
|
summary: null,
|
||||||
|
precipChance: maybe(d.precipitation_probability_max[i]),
|
||||||
|
precipMm: maybe(d.precipitation_sum[i]),
|
||||||
|
windKph: maybe(d.wind_speed_10m_max[i]),
|
||||||
|
gustKph: maybe(d.wind_gusts_10m_max[i]),
|
||||||
|
windDeg: maybe(d.wind_direction_10m_dominant[i]),
|
||||||
|
uvIndexMax: maybe(d.uv_index_max[i]),
|
||||||
|
humidity: null,
|
||||||
|
sunrise: d.sunrise[i] ? fromUnix(d.sunrise[i]!) : null,
|
||||||
|
sunset: d.sunset[i] ? fromUnix(d.sunset[i]!) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
134
lib/weather/providers/openweather/free.ts
Normal file
134
lib/weather/providers/openweather/free.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { fetchJson, fromUnix, maybe, maybeMsToKph, msToKph, round1 } from "../../http";
|
||||||
|
import type { DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
|
||||||
|
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenWeather FREE plan:
|
||||||
|
* /data/2.5/weather → current conditions
|
||||||
|
* /data/2.5/forecast → 5 days in 3-hour steps
|
||||||
|
* There is no daily endpoint, so we build days by grouping the 3-hour steps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.openweathermap.org/data/2.5";
|
||||||
|
|
||||||
|
interface OwmCurrentResponse {
|
||||||
|
dt: number;
|
||||||
|
timezone: number; // seconds from UTC
|
||||||
|
weather: OwmWeatherTag[];
|
||||||
|
main: { temp: number; feels_like: number; humidity: number; pressure: number };
|
||||||
|
visibility?: number; // metres
|
||||||
|
wind: { speed: number; deg?: number; gust?: number }; // m/s
|
||||||
|
clouds?: { all: number };
|
||||||
|
rain?: { "1h"?: number };
|
||||||
|
snow?: { "1h"?: number };
|
||||||
|
sys: { sunrise: number; sunset: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwmForecastEntry {
|
||||||
|
dt: number;
|
||||||
|
main: { temp: number; feels_like: number; temp_min: number; temp_max: number; humidity: number };
|
||||||
|
weather: OwmWeatherTag[];
|
||||||
|
wind: { speed: number; deg?: number; gust?: number };
|
||||||
|
pop?: number;
|
||||||
|
rain?: { "3h"?: number };
|
||||||
|
snow?: { "3h"?: number };
|
||||||
|
sys: { pod: "d" | "n" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwmForecastResponse {
|
||||||
|
list: OwmForecastEntry[];
|
||||||
|
city: { timezone: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHourly(e: OwmForecastEntry): HourlyForecast {
|
||||||
|
return {
|
||||||
|
time: fromUnix(e.dt),
|
||||||
|
tempC: round1(e.main.temp),
|
||||||
|
feelsLikeC: round1(e.main.feels_like),
|
||||||
|
condition: toCondition(e.weather[0].id),
|
||||||
|
isDay: e.sys.pod === "d",
|
||||||
|
precipChance: popToPercent(e.pop),
|
||||||
|
precipMm: precipitation(e.rain?.["3h"], e.snow?.["3h"]),
|
||||||
|
windKph: msToKph(e.wind.speed),
|
||||||
|
gustKph: maybeMsToKph(e.wind.gust),
|
||||||
|
windDeg: maybe(e.wind.deg),
|
||||||
|
humidity: e.main.humidity,
|
||||||
|
uvIndex: null, // not on the free plan
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups the 3-hour steps by local calendar day and summarises each day. */
|
||||||
|
function buildDaily(response: OwmForecastResponse, today: OwmCurrentResponse): DailyForecast[] {
|
||||||
|
const offset = response.city.timezone;
|
||||||
|
const localDay = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).toISOString().slice(0, 10);
|
||||||
|
const localHour = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).getUTCHours();
|
||||||
|
|
||||||
|
const entriesByDay = new Map<string, OwmForecastEntry[]>();
|
||||||
|
for (const entry of response.list) {
|
||||||
|
const day = localDay(entry);
|
||||||
|
entriesByDay.set(day, [...(entriesByDay.get(day) ?? []), entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...entriesByDay.values()].map((entries, index) => {
|
||||||
|
// The step nearest midday best represents "what the day looks like".
|
||||||
|
const midday = entries.reduce((best, e) => (Math.abs(localHour(e) - 12) < Math.abs(localHour(best) - 12) ? e : best));
|
||||||
|
const pops = entries.map((e) => popToPercent(e.pop) ?? 0);
|
||||||
|
const gusts = entries.map((e) => e.wind.gust ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: fromUnix(midday.dt),
|
||||||
|
highC: round1(Math.max(...entries.map((e) => e.main.temp_max))),
|
||||||
|
lowC: round1(Math.min(...entries.map((e) => e.main.temp_min))),
|
||||||
|
condition: toCondition(midday.weather[0].id),
|
||||||
|
summary: midday.weather[0].description,
|
||||||
|
precipChance: Math.max(...pops),
|
||||||
|
precipMm: round1(entries.reduce((sum, e) => sum + (precipitation(e.rain?.["3h"], e.snow?.["3h"]) ?? 0), 0)),
|
||||||
|
windKph: msToKph(Math.max(...entries.map((e) => e.wind.speed))),
|
||||||
|
gustKph: Math.max(...gusts) > 0 ? msToKph(Math.max(...gusts)) : null,
|
||||||
|
windDeg: maybe(midday.wind.deg),
|
||||||
|
uvIndexMax: null,
|
||||||
|
humidity: Math.round(entries.reduce((sum, e) => sum + e.main.humidity, 0) / entries.length),
|
||||||
|
// Only today's sun times are available on this plan.
|
||||||
|
sunrise: index === 0 ? fromUnix(today.sys.sunrise) : null,
|
||||||
|
sunset: index === 0 ? fromUnix(today.sys.sunset) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreeForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
|
||||||
|
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
|
||||||
|
|
||||||
|
// Both requests are independent, so run them at the same time.
|
||||||
|
const [now, forecast] = await Promise.all([
|
||||||
|
fetchJson<OwmCurrentResponse>(`${BASE_URL}/weather?${query}`, OWM_LABEL),
|
||||||
|
fetchJson<OwmForecastResponse>(`${BASE_URL}/forecast?${query}`, OWM_LABEL),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tag = now.weather[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeZone: null, // only a UTC offset is given; the API route fills in the name
|
||||||
|
hourlyStepHours: 3,
|
||||||
|
alerts: [],
|
||||||
|
current: {
|
||||||
|
time: fromUnix(now.dt),
|
||||||
|
tempC: round1(now.main.temp),
|
||||||
|
feelsLikeC: round1(now.main.feels_like),
|
||||||
|
condition: toCondition(tag.id),
|
||||||
|
description: tag.description,
|
||||||
|
isDay: tag.icon.endsWith("d"),
|
||||||
|
humidity: now.main.humidity,
|
||||||
|
windKph: msToKph(now.wind.speed),
|
||||||
|
gustKph: maybeMsToKph(now.wind.gust),
|
||||||
|
windDeg: maybe(now.wind.deg),
|
||||||
|
pressureHpa: now.main.pressure,
|
||||||
|
dewPointC: null,
|
||||||
|
visibilityKm: now.visibility === undefined ? null : round1(now.visibility / 1000),
|
||||||
|
uvIndex: null,
|
||||||
|
cloudCover: maybe(now.clouds?.all),
|
||||||
|
precipMm: precipitation(now.rain?.["1h"], now.snow?.["1h"]),
|
||||||
|
},
|
||||||
|
hourly: forecast.list.map(toHourly),
|
||||||
|
daily: buildDaily(forecast, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
26
lib/weather/providers/openweather/index.ts
Normal file
26
lib/weather/providers/openweather/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { readKey } from "../../http";
|
||||||
|
import type { WeatherProvider } from "../../types";
|
||||||
|
import { getFreeForecast } from "./free";
|
||||||
|
import { getOneCallForecast } from "./onecall";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenWeather (the default provider).
|
||||||
|
*
|
||||||
|
* Set OPENWEATHER_PLAN in .env.local:
|
||||||
|
* free (default) → 5 days in 3-hour steps
|
||||||
|
* onecall → One Call 4.0: 48 hours hourly + daily, plus dew point and UV
|
||||||
|
*/
|
||||||
|
const usesOneCall = () => process.env.OPENWEATHER_PLAN === "onecall";
|
||||||
|
|
||||||
|
export const openWeather: WeatherProvider = {
|
||||||
|
id: "openweather",
|
||||||
|
label: "OpenWeather",
|
||||||
|
envKey: "OPENWEATHER_API_KEY",
|
||||||
|
signupUrl: "https://home.openweathermap.org/users/sign_up",
|
||||||
|
limits: () => (usesOneCall() ? { forecastDays: 8, hourlyStepHours: 1 } : { forecastDays: 5, hourlyStepHours: 3 }),
|
||||||
|
|
||||||
|
getForecast(lat, lon) {
|
||||||
|
const key = readKey("OPENWEATHER_API_KEY");
|
||||||
|
return usesOneCall() ? getOneCallForecast(lat, lon, key) : getFreeForecast(lat, lon, key);
|
||||||
|
},
|
||||||
|
};
|
||||||
146
lib/weather/providers/openweather/onecall.ts
Normal file
146
lib/weather/providers/openweather/onecall.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { fetchJson, fromUnix, maybe, maybeMsToKph, MAX_DAYS, msToKph, ProviderError, round1 } from "../../http";
|
||||||
|
import type { CurrentWeather, DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
|
||||||
|
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenWeather ONE CALL 4.0 (needs a separate "One Call" subscription;
|
||||||
|
* 1,000 calls/day free, card on file). Docs: https://openweathermap.org/api/one-call-4
|
||||||
|
*
|
||||||
|
* /data/4.0/onecall/current → now
|
||||||
|
* /data/4.0/onecall/timeline/1h → hourly, 48 h of forecast, 20 records per page
|
||||||
|
* /data/4.0/onecall/timeline/1day → daily, 10 records per page
|
||||||
|
*
|
||||||
|
* Timelines are paginated with a `next` URL, and each page costs one call.
|
||||||
|
* A full load here is about 5 calls; the 10-minute cache keeps that sane.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.openweathermap.org/data/4.0/onecall";
|
||||||
|
const HOURS_AVAILABLE = 48;
|
||||||
|
|
||||||
|
interface OcRecord {
|
||||||
|
dt: number;
|
||||||
|
temp: number | { min: number; max: number; day: number };
|
||||||
|
feels_like: number | { day: number };
|
||||||
|
humidity: number;
|
||||||
|
pressure: number;
|
||||||
|
dew_point: number;
|
||||||
|
uvi: number;
|
||||||
|
clouds: number;
|
||||||
|
visibility?: number; // metres
|
||||||
|
wind_speed: number;
|
||||||
|
wind_gust?: number;
|
||||||
|
wind_deg: number;
|
||||||
|
pop?: number;
|
||||||
|
rain?: number | { "1h": number };
|
||||||
|
snow?: number | { "1h": number };
|
||||||
|
weather: OwmWeatherTag[];
|
||||||
|
sunrise?: number;
|
||||||
|
sunset?: number;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OcPage {
|
||||||
|
data: OcRecord[];
|
||||||
|
next?: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rain/snow is a number on daily records and `{ "1h": n }` on current/hourly ones. */
|
||||||
|
const volume = (v: OcRecord["rain"]) => (typeof v === "number" ? v : v?.["1h"]);
|
||||||
|
|
||||||
|
/** Follows `next` links until we have `wanted` records or the timeline ends. */
|
||||||
|
async function fetchTimeline(firstUrl: string, key: string, wanted: number): Promise<OcRecord[]> {
|
||||||
|
const records: OcRecord[] = [];
|
||||||
|
let url: string | undefined = firstUrl;
|
||||||
|
|
||||||
|
while (url && records.length < wanted) {
|
||||||
|
const page: OcPage = await fetchJson<OcPage>(url, OWM_LABEL);
|
||||||
|
records.push(...page.data);
|
||||||
|
// Make sure follow-up pages carry the key even if `next` omits it.
|
||||||
|
url = page.next ? (page.next.includes("appid=") ? page.next : `${page.next}&appid=${key}`) : undefined;
|
||||||
|
}
|
||||||
|
return records.slice(0, wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHourly(r: OcRecord): HourlyForecast {
|
||||||
|
return {
|
||||||
|
time: fromUnix(r.dt),
|
||||||
|
tempC: round1(r.temp as number),
|
||||||
|
feelsLikeC: round1(r.feels_like as number),
|
||||||
|
condition: toCondition(r.weather[0].id),
|
||||||
|
isDay: r.weather[0].icon.endsWith("d"),
|
||||||
|
precipChance: popToPercent(r.pop),
|
||||||
|
precipMm: precipitation(volume(r.rain), volume(r.snow)),
|
||||||
|
windKph: msToKph(r.wind_speed),
|
||||||
|
gustKph: maybeMsToKph(r.wind_gust),
|
||||||
|
windDeg: r.wind_deg,
|
||||||
|
humidity: r.humidity,
|
||||||
|
uvIndex: maybe(r.uvi),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDaily(r: OcRecord): DailyForecast {
|
||||||
|
const temp = r.temp as { min: number; max: number };
|
||||||
|
return {
|
||||||
|
time: fromUnix(r.dt), // OpenWeather stamps daily records at local midday
|
||||||
|
highC: round1(temp.max),
|
||||||
|
lowC: round1(temp.min),
|
||||||
|
condition: toCondition(r.weather[0].id),
|
||||||
|
summary: r.summary ?? r.weather[0].description,
|
||||||
|
precipChance: popToPercent(r.pop),
|
||||||
|
precipMm: precipitation(volume(r.rain), volume(r.snow)),
|
||||||
|
windKph: msToKph(r.wind_speed),
|
||||||
|
gustKph: maybeMsToKph(r.wind_gust),
|
||||||
|
windDeg: r.wind_deg,
|
||||||
|
uvIndexMax: maybe(r.uvi),
|
||||||
|
humidity: r.humidity,
|
||||||
|
sunrise: r.sunrise ? fromUnix(r.sunrise) : null,
|
||||||
|
sunset: r.sunset ? fromUnix(r.sunset) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCurrent(r: OcRecord): CurrentWeather {
|
||||||
|
const hour = toHourly(r);
|
||||||
|
return {
|
||||||
|
time: hour.time,
|
||||||
|
tempC: hour.tempC,
|
||||||
|
feelsLikeC: hour.feelsLikeC ?? hour.tempC,
|
||||||
|
condition: hour.condition,
|
||||||
|
description: r.weather[0].description,
|
||||||
|
isDay: hour.isDay,
|
||||||
|
humidity: r.humidity,
|
||||||
|
windKph: hour.windKph,
|
||||||
|
gustKph: hour.gustKph,
|
||||||
|
windDeg: hour.windDeg,
|
||||||
|
pressureHpa: r.pressure,
|
||||||
|
dewPointC: round1(r.dew_point),
|
||||||
|
visibilityKm: r.visibility === undefined ? null : round1(r.visibility / 1000),
|
||||||
|
uvIndex: maybe(r.uvi),
|
||||||
|
cloudCover: r.clouds,
|
||||||
|
precipMm: hour.precipMm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOneCallForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
|
||||||
|
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const startOfHour = nowSeconds - (nowSeconds % 3600);
|
||||||
|
|
||||||
|
const [currentPage, hourly, daily] = await Promise.all([
|
||||||
|
fetchJson<OcPage>(`${BASE_URL}/current?${query}`, OWM_LABEL),
|
||||||
|
fetchTimeline(`${BASE_URL}/timeline/1h?${query}&start=${startOfHour}`, key, HOURS_AVAILABLE),
|
||||||
|
fetchTimeline(`${BASE_URL}/timeline/1day?${query}&start=${nowSeconds}`, key, MAX_DAYS),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentRecord = currentPage.data[0];
|
||||||
|
if (!currentRecord) throw new ProviderError("OpenWeather returned no current reading", 502);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeZone: currentPage.timezone ?? null,
|
||||||
|
hourlyStepHours: 1,
|
||||||
|
alerts: [], // 4.0 only returns alert IDs; the API route adds NWS alerts for US locations
|
||||||
|
current: toCurrent(currentRecord),
|
||||||
|
hourly: hourly.map(toHourly),
|
||||||
|
daily: daily.map(toDaily),
|
||||||
|
};
|
||||||
|
}
|
||||||
32
lib/weather/providers/openweather/shared.ts
Normal file
32
lib/weather/providers/openweather/shared.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Condition } from "../../types";
|
||||||
|
|
||||||
|
/** Shared by both OpenWeather plans. */
|
||||||
|
|
||||||
|
export const OWM_LABEL = "OpenWeather";
|
||||||
|
|
||||||
|
export interface OwmWeatherTag {
|
||||||
|
id: number;
|
||||||
|
description: string;
|
||||||
|
icon: string; // ends in "d" (day) or "n" (night)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Condition codes: https://openweathermap.org/weather-conditions */
|
||||||
|
export function toCondition(code: number): Condition {
|
||||||
|
if (code >= 200 && code < 300) return "storm";
|
||||||
|
if (code >= 300 && code < 600) return "rain"; // drizzle + rain
|
||||||
|
if (code >= 600 && code < 700) return "snow";
|
||||||
|
if (code >= 700 && code < 800) return "fog"; // mist, haze, smoke, dust…
|
||||||
|
if (code === 800) return "clear";
|
||||||
|
return "clouds";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OpenWeather gives probability of precipitation as 0–1; we use 0–100. */
|
||||||
|
export function popToPercent(pop: number | undefined): number | null {
|
||||||
|
return pop === undefined ? null : Math.round(pop * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rain + snow volume, either of which may be missing. */
|
||||||
|
export function precipitation(rain?: number, snow?: number): number | null {
|
||||||
|
if (rain === undefined && snow === undefined) return null;
|
||||||
|
return Math.round(((rain ?? 0) + (snow ?? 0)) * 10) / 10;
|
||||||
|
}
|
||||||
138
lib/weather/providers/tomorrow.ts
Normal file
138
lib/weather/providers/tomorrow.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { CONDITION_LABELS } from "../describe";
|
||||||
|
import { fetchJson, maybe, maybeMsToKph, MAX_HOURS, msToKph, nextHours, ProviderError, readKey, round1 } from "../http";
|
||||||
|
import type { Condition, WeatherProvider } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tomorrow.io — one forecast request with hourly (≈120 h) and daily (≈6 days) timelines.
|
||||||
|
* "Current" is the first hourly entry, which Tomorrow.io sets to the present hour.
|
||||||
|
* Free plan: 25 requests/hour, so the 10-minute cache matters here.
|
||||||
|
* Docs: https://docs.tomorrow.io/reference/weather-forecast
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TmrwHourlyValues {
|
||||||
|
temperature: number;
|
||||||
|
temperatureApparent: number;
|
||||||
|
humidity: number;
|
||||||
|
windSpeed: number; // m/s with units=metric
|
||||||
|
windGust?: number;
|
||||||
|
windDirection?: number;
|
||||||
|
weatherCode: number;
|
||||||
|
precipitationProbability?: number;
|
||||||
|
rainIntensity?: number; // mm/h
|
||||||
|
snowIntensity?: number;
|
||||||
|
uvIndex?: number;
|
||||||
|
pressureSurfaceLevel?: number;
|
||||||
|
dewPoint?: number;
|
||||||
|
visibility?: number; // km
|
||||||
|
cloudCover?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TmrwDailyValues {
|
||||||
|
temperatureMax: number;
|
||||||
|
temperatureMin: number;
|
||||||
|
weatherCodeMax: number;
|
||||||
|
precipitationProbabilityMax?: number;
|
||||||
|
rainAccumulationSum?: number;
|
||||||
|
snowAccumulationSum?: number;
|
||||||
|
windSpeedMax?: number;
|
||||||
|
windGustMax?: number;
|
||||||
|
windDirectionAvg?: number;
|
||||||
|
uvIndexMax?: number;
|
||||||
|
humidityAvg?: number;
|
||||||
|
sunriseTime?: string;
|
||||||
|
sunsetTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TmrwResponse {
|
||||||
|
timelines: {
|
||||||
|
hourly: { time: string; values: TmrwHourlyValues }[];
|
||||||
|
daily: { time: string; values: TmrwDailyValues }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weather codes: https://docs.tomorrow.io/reference/data-layers-weather-codes */
|
||||||
|
function toCondition(code: number): Condition {
|
||||||
|
if (code === 1000 || code === 1100) return "clear";
|
||||||
|
if (code === 8000) return "storm";
|
||||||
|
if (code >= 2000 && code < 3000) return "fog";
|
||||||
|
if (code >= 4000 && code < 5000) return "rain";
|
||||||
|
if (code >= 5000 && code < 6000) return "snow";
|
||||||
|
if (code >= 6000 && code < 7000) return "rain"; // freezing rain / drizzle
|
||||||
|
if (code >= 7000 && code < 8000) return "snow"; // ice pellets
|
||||||
|
return "clouds";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daytime if the instant falls between sunrise and sunset of a day that has them. */
|
||||||
|
function isDaytime(instant: string, days: TmrwResponse["timelines"]["daily"]): boolean {
|
||||||
|
const t = Date.parse(instant);
|
||||||
|
return days.some(({ values: v }) =>
|
||||||
|
v.sunriseTime && v.sunsetTime ? t >= Date.parse(v.sunriseTime) && t < Date.parse(v.sunsetTime) : false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tomorrowIo: WeatherProvider = {
|
||||||
|
id: "tomorrow",
|
||||||
|
label: "Tomorrow.io",
|
||||||
|
envKey: "TOMORROW_API_KEY",
|
||||||
|
signupUrl: "https://app.tomorrow.io/signup",
|
||||||
|
limits: () => ({ forecastDays: 6, hourlyStepHours: 1 }),
|
||||||
|
|
||||||
|
async getForecast(lat, lon) {
|
||||||
|
const key = readKey("TOMORROW_API_KEY");
|
||||||
|
const url = `https://api.tomorrow.io/v4/weather/forecast?location=${lat},${lon}×teps=1h,1d&units=metric&apikey=${key}`;
|
||||||
|
const { timelines } = await fetchJson<TmrwResponse>(url, "Tomorrow.io");
|
||||||
|
|
||||||
|
const hourly = timelines.hourly.map(({ time, values: v }) => ({
|
||||||
|
time: new Date(time).toISOString(),
|
||||||
|
tempC: round1(v.temperature),
|
||||||
|
feelsLikeC: round1(v.temperatureApparent),
|
||||||
|
condition: toCondition(v.weatherCode),
|
||||||
|
isDay: isDaytime(time, timelines.daily),
|
||||||
|
precipChance: maybe(v.precipitationProbability),
|
||||||
|
precipMm: maybe((v.rainIntensity ?? 0) + (v.snowIntensity ?? 0)),
|
||||||
|
windKph: msToKph(v.windSpeed),
|
||||||
|
gustKph: maybeMsToKph(v.windGust),
|
||||||
|
windDeg: maybe(v.windDirection),
|
||||||
|
humidity: maybe(v.humidity),
|
||||||
|
uvIndex: maybe(v.uvIndex),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const now = timelines.hourly[0];
|
||||||
|
if (!now) throw new ProviderError("Tomorrow.io returned an empty forecast", 502);
|
||||||
|
const condition = toCondition(now.values.weatherCode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeZone: null, // Tomorrow.io doesn't say; the API route fills it in
|
||||||
|
hourlyStepHours: 1,
|
||||||
|
alerts: [],
|
||||||
|
current: {
|
||||||
|
...hourly[0],
|
||||||
|
feelsLikeC: hourly[0].feelsLikeC ?? hourly[0].tempC,
|
||||||
|
humidity: Math.round(now.values.humidity),
|
||||||
|
description: CONDITION_LABELS[condition],
|
||||||
|
pressureHpa: maybe(now.values.pressureSurfaceLevel),
|
||||||
|
dewPointC: maybe(now.values.dewPoint),
|
||||||
|
visibilityKm: maybe(now.values.visibility),
|
||||||
|
cloudCover: maybe(now.values.cloudCover),
|
||||||
|
precipMm: hourly[0].precipMm,
|
||||||
|
},
|
||||||
|
hourly: nextHours(hourly, MAX_HOURS),
|
||||||
|
daily: timelines.daily.map(({ time, values: v }) => ({
|
||||||
|
time: new Date(time).toISOString(), // Tomorrow.io starts each day at 6 am local
|
||||||
|
highC: round1(v.temperatureMax),
|
||||||
|
lowC: round1(v.temperatureMin),
|
||||||
|
condition: toCondition(v.weatherCodeMax),
|
||||||
|
summary: null,
|
||||||
|
precipChance: maybe(v.precipitationProbabilityMax),
|
||||||
|
precipMm: maybe((v.rainAccumulationSum ?? 0) + (v.snowAccumulationSum ?? 0)),
|
||||||
|
windKph: maybeMsToKph(v.windSpeedMax),
|
||||||
|
gustKph: maybeMsToKph(v.windGustMax),
|
||||||
|
windDeg: maybe(v.windDirectionAvg),
|
||||||
|
uvIndexMax: maybe(v.uvIndexMax),
|
||||||
|
humidity: maybe(v.humidityAvg),
|
||||||
|
sunrise: v.sunriseTime ?? null,
|
||||||
|
sunset: v.sunsetTime ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
192
lib/weather/providers/weatherapi.ts
Normal file
192
lib/weather/providers/weatherapi.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { toSeverity } from "../describe";
|
||||||
|
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, readKey, round1 } from "../http";
|
||||||
|
import type { Condition, WeatherProvider } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WeatherAPI.com — one request returns current, daily, hourly and alerts.
|
||||||
|
* The free plan returns 3 days; paid plans return up to 14. We ask for 14
|
||||||
|
* and use whatever comes back.
|
||||||
|
* Docs: https://www.weatherapi.com/docs/
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface WapiCondition {
|
||||||
|
code: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WapiHour {
|
||||||
|
time_epoch: number;
|
||||||
|
temp_c: number;
|
||||||
|
feelslike_c: number;
|
||||||
|
is_day: 0 | 1;
|
||||||
|
condition: WapiCondition;
|
||||||
|
wind_kph: number;
|
||||||
|
wind_degree: number;
|
||||||
|
gust_kph: number;
|
||||||
|
humidity: number;
|
||||||
|
chance_of_rain: number;
|
||||||
|
chance_of_snow: number;
|
||||||
|
precip_mm: number;
|
||||||
|
uv: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WapiDay {
|
||||||
|
date: string; // "2026-10-21"
|
||||||
|
day: {
|
||||||
|
maxtemp_c: number;
|
||||||
|
mintemp_c: number;
|
||||||
|
maxwind_kph: number;
|
||||||
|
totalprecip_mm: number;
|
||||||
|
avghumidity: number;
|
||||||
|
daily_chance_of_rain: number;
|
||||||
|
daily_chance_of_snow: number;
|
||||||
|
uv: number;
|
||||||
|
condition: WapiCondition;
|
||||||
|
};
|
||||||
|
astro: { sunrise: string; sunset: string }; // "07:31 AM", local time
|
||||||
|
hour: WapiHour[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WapiResponse {
|
||||||
|
location: { tz_id: string; localtime_epoch: number; localtime: string };
|
||||||
|
current: {
|
||||||
|
last_updated_epoch: number;
|
||||||
|
temp_c: number;
|
||||||
|
feelslike_c: number;
|
||||||
|
humidity: number;
|
||||||
|
wind_kph: number;
|
||||||
|
wind_degree: number;
|
||||||
|
gust_kph: number;
|
||||||
|
pressure_mb: number;
|
||||||
|
dewpoint_c?: number;
|
||||||
|
vis_km: number;
|
||||||
|
uv: number;
|
||||||
|
cloud: number;
|
||||||
|
precip_mm: number;
|
||||||
|
is_day: 0 | 1;
|
||||||
|
condition: WapiCondition;
|
||||||
|
};
|
||||||
|
forecast: { forecastday: WapiDay[] };
|
||||||
|
alerts?: {
|
||||||
|
alert: { headline: string; event: string; severity: string; effective: string; expires: string; desc: string; instruction: string }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition codes: https://www.weatherapi.com/docs/weather_conditions.json
|
||||||
|
const STORM_CODES = [1087, 1273, 1276, 1279, 1282];
|
||||||
|
const FOG_CODES = [1030, 1135, 1147];
|
||||||
|
const CLOUD_CODES = [1003, 1006, 1009];
|
||||||
|
const SNOW_CODES = [1066, 1069, 1072, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1249, 1252, 1255, 1258, 1261, 1264];
|
||||||
|
|
||||||
|
function toCondition(code: number): Condition {
|
||||||
|
if (code === 1000) return "clear";
|
||||||
|
if (STORM_CODES.includes(code)) return "storm";
|
||||||
|
if (FOG_CODES.includes(code)) return "fog";
|
||||||
|
if (CLOUD_CODES.includes(code)) return "clouds";
|
||||||
|
if (SNOW_CODES.includes(code)) return "snow";
|
||||||
|
return "rain"; // everything left is some flavour of rain or drizzle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sunrise comes as "07:31 AM" in local time. To turn that into a real instant
|
||||||
|
* we need the location's UTC offset, which we work out from `localtime`
|
||||||
|
* (the local wall-clock time) versus `localtime_epoch` (the true instant).
|
||||||
|
*/
|
||||||
|
function utcOffsetSeconds(location: WapiResponse["location"]): number {
|
||||||
|
const wallClockAsUtc = Date.parse(`${location.localtime.replace(" ", "T")}:00Z`) / 1000;
|
||||||
|
return Math.round((wallClockAsUtc - location.localtime_epoch) / 60) * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localTimeToIso(date: string, clock: string, offsetSeconds: number): string | null {
|
||||||
|
const match = clock.match(/(\d+):(\d+)\s*(AM|PM)/i);
|
||||||
|
if (!match) return null; // e.g. "No sunrise" near the poles
|
||||||
|
let hours = Number(match[1]) % 12;
|
||||||
|
if (match[3].toUpperCase() === "PM") hours += 12;
|
||||||
|
const wallClockAsUtc = Date.parse(`${date}T00:00:00Z`) + (hours * 60 + Number(match[2])) * 60_000;
|
||||||
|
return new Date(wallClockAsUtc - offsetSeconds * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const weatherApi: WeatherProvider = {
|
||||||
|
id: "weatherapi",
|
||||||
|
label: "WeatherAPI",
|
||||||
|
envKey: "WEATHERAPI_API_KEY",
|
||||||
|
signupUrl: "https://www.weatherapi.com/signup.aspx",
|
||||||
|
// Set WEATHERAPI_PLAN=paid in .env.local if your plan includes 14 days.
|
||||||
|
limits: () => ({ forecastDays: process.env.WEATHERAPI_PLAN === "paid" ? 14 : 3, hourlyStepHours: 1 }),
|
||||||
|
|
||||||
|
async getForecast(lat, lon) {
|
||||||
|
const key = readKey("WEATHERAPI_API_KEY");
|
||||||
|
const url = `https://api.weatherapi.com/v1/forecast.json?key=${key}&q=${lat},${lon}&days=${MAX_DAYS}&aqi=no&alerts=yes`;
|
||||||
|
const data = await fetchJson<WapiResponse>(url, "WeatherAPI");
|
||||||
|
|
||||||
|
const now = data.current;
|
||||||
|
const offset = utcOffsetSeconds(data.location);
|
||||||
|
const days = data.forecast.forecastday;
|
||||||
|
|
||||||
|
const hourly = days.flatMap((day) =>
|
||||||
|
day.hour.map((h) => ({
|
||||||
|
time: fromUnix(h.time_epoch),
|
||||||
|
tempC: round1(h.temp_c),
|
||||||
|
feelsLikeC: round1(h.feelslike_c),
|
||||||
|
condition: toCondition(h.condition.code),
|
||||||
|
isDay: h.is_day === 1,
|
||||||
|
precipChance: Math.max(h.chance_of_rain, h.chance_of_snow),
|
||||||
|
precipMm: maybe(h.precip_mm),
|
||||||
|
windKph: round1(h.wind_kph),
|
||||||
|
gustKph: maybe(h.gust_kph),
|
||||||
|
windDeg: h.wind_degree,
|
||||||
|
humidity: h.humidity,
|
||||||
|
uvIndex: maybe(h.uv),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeZone: data.location.tz_id,
|
||||||
|
hourlyStepHours: 1,
|
||||||
|
current: {
|
||||||
|
time: fromUnix(now.last_updated_epoch),
|
||||||
|
tempC: round1(now.temp_c),
|
||||||
|
feelsLikeC: round1(now.feelslike_c),
|
||||||
|
condition: toCondition(now.condition.code),
|
||||||
|
description: now.condition.text.toLowerCase(),
|
||||||
|
isDay: now.is_day === 1,
|
||||||
|
humidity: now.humidity,
|
||||||
|
windKph: round1(now.wind_kph),
|
||||||
|
gustKph: maybe(now.gust_kph),
|
||||||
|
windDeg: now.wind_degree,
|
||||||
|
pressureHpa: maybe(now.pressure_mb),
|
||||||
|
dewPointC: maybe(now.dewpoint_c),
|
||||||
|
visibilityKm: maybe(now.vis_km),
|
||||||
|
uvIndex: maybe(now.uv),
|
||||||
|
cloudCover: now.cloud,
|
||||||
|
precipMm: maybe(now.precip_mm),
|
||||||
|
},
|
||||||
|
hourly: nextHours(hourly, MAX_HOURS),
|
||||||
|
daily: days.map((day) => ({
|
||||||
|
time: `${day.date}T12:00:00.000Z`, // midday UTC lands on the right date almost everywhere
|
||||||
|
highC: round1(day.day.maxtemp_c),
|
||||||
|
lowC: round1(day.day.mintemp_c),
|
||||||
|
condition: toCondition(day.day.condition.code),
|
||||||
|
summary: day.day.condition.text,
|
||||||
|
precipChance: Math.max(day.day.daily_chance_of_rain, day.day.daily_chance_of_snow),
|
||||||
|
precipMm: maybe(day.day.totalprecip_mm),
|
||||||
|
windKph: maybe(day.day.maxwind_kph),
|
||||||
|
gustKph: null,
|
||||||
|
windDeg: null,
|
||||||
|
uvIndexMax: maybe(day.day.uv),
|
||||||
|
humidity: maybe(day.day.avghumidity),
|
||||||
|
sunrise: localTimeToIso(day.date, day.astro.sunrise, offset),
|
||||||
|
sunset: localTimeToIso(day.date, day.astro.sunset, offset),
|
||||||
|
})),
|
||||||
|
alerts: (data.alerts?.alert ?? []).map((a) => ({
|
||||||
|
title: a.headline || a.event,
|
||||||
|
severity: toSeverity(a.severity),
|
||||||
|
source: "WeatherAPI",
|
||||||
|
starts: a.effective || null,
|
||||||
|
ends: a.expires || null,
|
||||||
|
description: a.desc,
|
||||||
|
instruction: a.instruction || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
35
lib/weather/respond.ts
Normal file
35
lib/weather/respond.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { ProviderError } from "./http";
|
||||||
|
|
||||||
|
/** Turns any thrown error into a JSON `{ error }` response with a sensible status. */
|
||||||
|
export function errorResponse(error: unknown): NextResponse {
|
||||||
|
if (error instanceof ProviderError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Something went wrong reaching the weather service" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badRequest(message: string): NextResponse {
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejects cross-site browser requests so external pages can't hotlink your API/tile quotas. */
|
||||||
|
export function forbidCrossSite(request: NextRequest): NextResponse | null {
|
||||||
|
if (request.headers.get("sec-fetch-site") === "cross-site") {
|
||||||
|
return NextResponse.json({ error: "Cross-site requests are not permitted" }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a lat/lon pair from query params, or returns null if either is missing or invalid. */
|
||||||
|
export function parseCoords(params: URLSearchParams): { lat: number; lon: number } | null {
|
||||||
|
const rawLat = params.get("lat")?.trim() ?? "";
|
||||||
|
const rawLon = params.get("lon")?.trim() ?? "";
|
||||||
|
if (!rawLat || !rawLon) return null;
|
||||||
|
|
||||||
|
const lat = Number(rawLat);
|
||||||
|
const lon = Number(rawLon);
|
||||||
|
const valid = Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
|
||||||
|
return valid ? { lat, lon } : null;
|
||||||
|
}
|
||||||
144
lib/weather/types.ts
Normal file
144
lib/weather/types.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/**
|
||||||
|
* The one shape the UI understands.
|
||||||
|
*
|
||||||
|
* Every provider returns wildly different JSON. Each adapter in ./providers
|
||||||
|
* translates its provider's response into these types, so the UI never has
|
||||||
|
* to know which provider it is talking to.
|
||||||
|
*
|
||||||
|
* Rules every adapter follows:
|
||||||
|
* - Units are metric: °C, km/h, hPa, km, mm.
|
||||||
|
* - Times are ISO-8601 instants in UTC ("2026-10-21T23:00:00.000Z").
|
||||||
|
* The UI formats them in the location's time zone.
|
||||||
|
* - A reading the provider doesn't offer is `null`, and the UI hides it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProviderId = "openweather" | "weatherapi" | "tomorrow" | "openmeteo";
|
||||||
|
|
||||||
|
/** A deliberately small set of conditions — one per pixel icon. */
|
||||||
|
export type Condition = "clear" | "clouds" | "rain" | "storm" | "snow" | "fog";
|
||||||
|
|
||||||
|
export interface Place {
|
||||||
|
name: string;
|
||||||
|
region?: string;
|
||||||
|
country?: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentWeather {
|
||||||
|
time: string;
|
||||||
|
tempC: number;
|
||||||
|
feelsLikeC: number;
|
||||||
|
condition: Condition;
|
||||||
|
description: string; // human words, e.g. "light rain"
|
||||||
|
isDay: boolean;
|
||||||
|
humidity: number; // %
|
||||||
|
windKph: number;
|
||||||
|
gustKph: number | null;
|
||||||
|
windDeg: number | null; // direction the wind blows FROM, 0 = north
|
||||||
|
pressureHpa: number | null;
|
||||||
|
dewPointC: number | null;
|
||||||
|
visibilityKm: number | null;
|
||||||
|
uvIndex: number | null;
|
||||||
|
cloudCover: number | null; // %
|
||||||
|
precipMm: number | null; // in the last hour
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyForecast {
|
||||||
|
time: string; // start of the hour (or 3-hour block)
|
||||||
|
tempC: number;
|
||||||
|
feelsLikeC: number | null;
|
||||||
|
condition: Condition;
|
||||||
|
isDay: boolean;
|
||||||
|
precipChance: number | null; // %
|
||||||
|
precipMm: number | null;
|
||||||
|
windKph: number;
|
||||||
|
gustKph: number | null;
|
||||||
|
windDeg: number | null;
|
||||||
|
humidity: number | null;
|
||||||
|
uvIndex: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyForecast {
|
||||||
|
time: string; // any instant inside that local day (midday where possible)
|
||||||
|
highC: number;
|
||||||
|
lowC: number;
|
||||||
|
condition: Condition;
|
||||||
|
summary: string | null; // provider's own one-liner, if it has one
|
||||||
|
precipChance: number | null;
|
||||||
|
precipMm: number | null;
|
||||||
|
windKph: number | null; // daily max
|
||||||
|
gustKph: number | null;
|
||||||
|
windDeg: number | null;
|
||||||
|
uvIndexMax: number | null;
|
||||||
|
humidity: number | null; // daily average
|
||||||
|
sunrise: string | null;
|
||||||
|
sunset: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertSeverity = "extreme" | "severe" | "moderate" | "minor" | "unknown";
|
||||||
|
|
||||||
|
export interface WeatherAlert {
|
||||||
|
title: string;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
source: string; // who issued it, e.g. "NWS Twin Cities/Chanhassen MN"
|
||||||
|
starts: string | null;
|
||||||
|
ends: string | null;
|
||||||
|
description: string;
|
||||||
|
instruction: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AirQuality {
|
||||||
|
usAqi: number; // US EPA scale, 0–500
|
||||||
|
pm25: number | null; // µg/m³
|
||||||
|
pm10: number | null;
|
||||||
|
ozone: number | null;
|
||||||
|
no2: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one provider adapter returns. */
|
||||||
|
export interface ProviderForecast {
|
||||||
|
current: CurrentWeather;
|
||||||
|
hourly: HourlyForecast[];
|
||||||
|
daily: DailyForecast[];
|
||||||
|
hourlyStepHours: 1 | 3;
|
||||||
|
timeZone: string | null; // IANA name, if the provider tells us
|
||||||
|
alerts: WeatherAlert[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What /api/weather returns: the provider's forecast plus extras from other sources. */
|
||||||
|
export interface WeatherReport extends ProviderForecast {
|
||||||
|
provider: ProviderId;
|
||||||
|
timeZone: string | null;
|
||||||
|
airQuality: AirQuality | null;
|
||||||
|
alertSource: string | null;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contract each provider adapter fulfils. */
|
||||||
|
export interface WeatherProvider {
|
||||||
|
id: ProviderId;
|
||||||
|
label: string;
|
||||||
|
/** Name of the env var holding its key, or null if no key is needed. */
|
||||||
|
envKey: string | null;
|
||||||
|
signupUrl: string;
|
||||||
|
/** What this provider can deliver on the plan configured in .env.local. */
|
||||||
|
limits(): { forecastDays: number; hourlyStepHours: 1 | 3 };
|
||||||
|
getForecast(lat: number, lon: number): Promise<ProviderForecast>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safe-to-share provider info for the browser (never includes keys). */
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: ProviderId;
|
||||||
|
label: string;
|
||||||
|
ready: boolean;
|
||||||
|
signupUrl: string;
|
||||||
|
forecastDays: number;
|
||||||
|
hourlyStepHours: 1 | 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map overlays the radar panel can show. */
|
||||||
|
export interface RadarFrame {
|
||||||
|
time: string;
|
||||||
|
url: string; // Leaflet tile template with {z}/{x}/{y}
|
||||||
|
}
|
||||||
19
next.config.ts
Normal file
19
next.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
poweredByHeader: false,
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
1786
package-lock.json
generated
Normal file
1786
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "wayfarer-weather",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/cinzel": "^5.2.0",
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.2.0",
|
||||||
|
"@fontsource/silkscreen": "^5.2.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"next": "16.4.2",
|
||||||
|
"react": "19.3.1",
|
||||||
|
"react-dom": "19.3.1",
|
||||||
|
"react-leaflet": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"postcss": "^8.5.31",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue