"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) { 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) { 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 ( {/* Day dividers and names */} {dayStarts.map(({ i }) => ( {i > 0 && } {dayName(hours[i].time, timeZone)} ))} {/* Band 1: temperature */} {hasFeelsLike && } {hours.map((h, i) => i % labelEvery === 0 ? ( {Math.round(tempValue(h.tempC, units))}° ) : null, )} {/* Band 2: chance of precipitation */} Chance of precipitation {hours.map((h, i) => h.precipChance ? ( ) : null, )} {/* Hour labels */} {hours.map((h, i) => i % labelEvery === 0 ? ( {formatHour(h.time, timeZone)} ) : null, )} {/* Crosshair on the selected hour */} ); }