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

154 lines
7.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 { useState } from "react";
import { compass, dayName, formatHour, formatPrecip, formatTemp, formatWind, uvLabel, type Units } from "@/lib/display";
import { pixelLabel } from "@/lib/styles";
import type { HourlyForecast } from "@/lib/weather/types";
import { SkyIcon } from "../PixelArt";
import { ChoiceChips } from "../ui/ChoiceChips";
import { HourlyChart } from "./HourlyChart";
type Range = "24" | "48" | "168";
type Mode = "chart" | "table";
/** Chart sizing per range, in pixels per hour, so longer ranges stay scrollable but compact. */
const PX_PER_HOUR: Record<Range, number> = { "24": 34, "48": 22, "168": 10 };
const LABEL_EVERY_HOURS: Record<Range, number> = { "24": 3, "48": 6, "168": 12 };
/** Hourly forecast: range picker, chart or table, and a readout of the selected hour. */
export function HourlyView({ hourly, stepHours, units, timeZone }: {
hourly: HourlyForecast[];
stepHours: 1 | 3;
units: Units;
timeZone: string | null;
}) {
const [range, setRange] = useState<Range>("48");
const [mode, setMode] = useState<Mode>("chart");
const [selected, setSelected] = useState(0);
if (hourly.length === 0) {
return <p className={pixelLabel}>Hourly forecast unavailable for this location.</p>;
}
const availableHours = hourly.length * stepHours;
const ranges: { value: Range; label: string }[] = [
{ value: "24", label: "24 hours" },
{ value: "48", label: "48 hours" },
{ value: "168", label: "7 days" },
];
// Offer a range only if the provider delivers at least most of it.
const offered = ranges.filter((r) => r.value === "24" || availableHours >= Number(r.value) * 0.75);
const activeRange = offered.some((r) => r.value === range) ? range : offered[offered.length - 1].value;
const hours = hourly.slice(0, Math.ceil(Number(activeRange) / stepHours));
const current = hours[Math.min(selected, hours.length - 1)];
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<ChoiceChips legend="Hourly range" size="sm" choices={offered} selected={activeRange} onChange={(r) => { setRange(r); setSelected(0); }} />
<ChoiceChips
legend="Show as"
size="sm"
choices={[{ value: "chart", label: "Chart" }, { value: "table", label: "Table" }]}
selected={mode}
onChange={setMode}
/>
</div>
{stepHours === 3 && <p className={pixelLabel}>This provider forecasts in 3-hour steps.</p>}
{mode === "chart" ? (
<>
<HourReadout hour={current} units={units} timeZone={timeZone} />
{/* Legend: two series share the temperature band */}
<div className="flex gap-4 text-xs text-stone" aria-hidden="true">
<span className="flex items-center gap-1.5"><span className="h-0.5 w-5 bg-gold-bright" />Temperature</span>
<span className="flex items-center gap-1.5"><span className="w-5 border-t-[1.5px] border-dashed border-stone" />Feels like</span>
</div>
<div className="-mx-(--gutter) overflow-x-auto px-(--gutter) sm:mx-0 sm:px-0">
<HourlyChart
hours={hours}
units={units}
timeZone={timeZone}
pxPerPoint={PX_PER_HOUR[activeRange] * stepHours}
labelEvery={Math.max(1, LABEL_EVERY_HOURS[activeRange] / stepHours)}
selected={Math.min(selected, hours.length - 1)}
onSelect={setSelected}
/>
</div>
</>
) : (
<HourlyTable hours={hours} units={units} timeZone={timeZone} />
)}
</div>
);
}
/** The details of one hour, shown above the chart and driven by the crosshair. */
function HourReadout({ hour, units, timeZone }: { hour: HourlyForecast; units: Units; timeZone: string | null }) {
const facts = [
hour.feelsLikeC !== null && ["Feels like", formatTemp(hour.feelsLikeC, units)],
hour.precipChance !== null && ["Precip", `${hour.precipChance}%${hour.precipMm ? ` · ${formatPrecip(hour.precipMm, units)}` : ""}`],
["Wind", `${formatWind(hour.windKph, units)}${hour.windDeg !== null ? ` ${compass(hour.windDeg)}` : ""}${hour.gustKph ? `, gusts ${formatWind(hour.gustKph, units)}` : ""}`],
hour.humidity !== null && ["Humidity", `${hour.humidity}%`],
hour.uvIndex !== null && ["UV", `${Math.round(hour.uvIndex)} ${uvLabel(hour.uvIndex)}`],
].filter(Boolean) as [string, string][];
return (
<div className="flex items-center gap-4 border border-gold-dim bg-black p-3" aria-live="polite">
<SkyIcon condition={hour.condition} isDay={hour.isDay} size={40} className="flex-none" />
<div className="min-w-0">
<p className="font-display text-lg text-gold-bright">
{dayName(hour.time, timeZone)} {formatHour(hour.time, timeZone)} · {formatTemp(hour.tempC, units)}
</p>
<dl className="flex flex-wrap gap-x-4 gap-y-0.5 text-sm">
{facts.map(([label, value]) => (
<div key={label} className="flex gap-1.5">
<dt className="text-stone">{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</div>
</div>
);
}
/** Every hour as a table row — the accessible, exact-numbers view of the chart. */
function HourlyTable({ hours, units, timeZone }: { hours: HourlyForecast[]; units: Units; timeZone: string | null }) {
const cell = "px-3 py-2 text-right tabular-nums whitespace-nowrap";
return (
<div className="max-h-[480px] overflow-auto border border-gold-dim">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 bg-black font-pixel text-[0.7rem] text-stone">
<tr>
<th className="px-3 py-2 text-left font-normal">Time</th>
<th className={`${cell} font-normal`}>Temp</th>
<th className={`${cell} font-normal`}>Feels</th>
<th className={`${cell} font-normal`}>Precip</th>
<th className={`${cell} font-normal`}>Wind</th>
<th className={`${cell} font-normal`}>Humidity</th>
<th className={`${cell} font-normal`}>UV</th>
</tr>
</thead>
<tbody>
{hours.map((h) => (
<tr key={h.time} className="border-t border-gold-dim/50">
<td className="flex items-center gap-2 px-3 py-2 whitespace-nowrap">
<SkyIcon condition={h.condition} isDay={h.isDay} size={18} />
{dayName(h.time, timeZone)} {formatHour(h.time, timeZone)}
</td>
<td className={`${cell} text-gold-bright`}>{formatTemp(h.tempC, units)}</td>
<td className={cell}>{h.feelsLikeC !== null ? formatTemp(h.feelsLikeC, units) : "–"}</td>
<td className={cell}>{h.precipChance !== null ? `${h.precipChance}%` : "–"}</td>
<td className={cell}>{formatWind(h.windKph, units)}{h.windDeg !== null ? ` ${compass(h.windDeg)}` : ""}</td>
<td className={cell}>{h.humidity !== null ? `${h.humidity}%` : "–"}</td>
<td className={cell}>{h.uvIndex !== null ? Math.round(h.uvIndex) : "–"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}