wayfarer-weather/components/forecast/HourlyChart.tsx
Cayden Schwechten 6250d23297 init
2026-10-31 21:20:16 -05:00

145 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}