wayfarer-weather/components/forecast/HourlyChart.tsx

146 lines
6.1 KiB
TypeScript
Raw Permalink Normal View History

2026-10-31 21:20:16 -05:00
"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>
);
}