154 lines
7.1 KiB
TypeScript
154 lines
7.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|