145 lines
6.1 KiB
TypeScript
145 lines
6.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|