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