init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue