init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
122
components/forecast/DailyView.tsx
Normal file
122
components/forecast/DailyView.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
compass, dayName, formatClock, formatDuration, formatPrecip, formatShortDate, formatTemp, formatWind,
|
||||
tempValue, uvLabel, type Units,
|
||||
} from "@/lib/display";
|
||||
import { pixelLabel } from "@/lib/styles";
|
||||
import type { DailyForecast } from "@/lib/weather/types";
|
||||
import { SkyIcon } from "../PixelArt";
|
||||
|
||||
/**
|
||||
* The daily forecast (up to 14 days) as rows:
|
||||
*
|
||||
* Today Oct 21 [icon] 60% 39° ━━━━━█████━━━━ 52°
|
||||
*
|
||||
* The bar shows each day's low→high against the whole period's range,
|
||||
* so warm and cold days stand out at a glance. Tap a row for its details.
|
||||
*/
|
||||
export function DailyView({ daily, units, timeZone, providerLabel, maxDaysOnPlan }: {
|
||||
daily: DailyForecast[];
|
||||
units: Units;
|
||||
timeZone: string | null;
|
||||
providerLabel: string;
|
||||
maxDaysOnPlan: number;
|
||||
}) {
|
||||
const [openDay, setOpenDay] = useState<string | null>(null);
|
||||
|
||||
if (daily.length === 0) {
|
||||
return <p className={pixelLabel}>Daily forecast unavailable for this location.</p>;
|
||||
}
|
||||
|
||||
const periodLow = Math.min(...daily.map((d) => tempValue(d.lowC, units)));
|
||||
const periodHigh = Math.max(...daily.map((d) => tempValue(d.highC, units)));
|
||||
const span = Math.max(1, periodHigh - periodLow);
|
||||
const percentAlong = (celsius: number) => ((tempValue(celsius, units) - periodLow) / span) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ol className="border border-gold-dim">
|
||||
{daily.map((day) => {
|
||||
const isOpen = openDay === day.time;
|
||||
return (
|
||||
<li key={day.time} className="border-t border-gold-dim/50 first:border-t-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setOpenDay(isOpen ? null : day.time)}
|
||||
className="grid w-full cursor-pointer grid-cols-[4.5rem_28px_2.5rem_2.25rem_1fr_2.25rem_14px] items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-gold/6 sm:grid-cols-[7rem_32px_3rem_2.5rem_1fr_2.5rem_16px] sm:gap-3 sm:px-4"
|
||||
>
|
||||
<span className="leading-tight">
|
||||
<span className="block font-medium">{dayName(day.time, timeZone)}</span>
|
||||
<span className="block text-xs text-stone">{formatShortDate(day.time, timeZone)}</span>
|
||||
</span>
|
||||
<SkyIcon condition={day.condition} size={28} />
|
||||
<span className="font-pixel text-xs text-teal">
|
||||
{day.precipChance !== null && day.precipChance >= 10 ? `${day.precipChance}%` : ""}
|
||||
</span>
|
||||
<span className="text-right font-display text-stone tabular-nums">{formatTemp(day.lowC, units)}</span>
|
||||
{/* Range bar: the gold segment spans this day's low → high */}
|
||||
<span className="relative h-1.5 bg-gold-dim/40" aria-hidden="true">
|
||||
<span
|
||||
className="absolute inset-y-0 bg-linear-to-r from-gold to-gold-bright"
|
||||
style={{ left: `${percentAlong(day.lowC)}%`, right: `${100 - percentAlong(day.highC)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-display text-gold-bright tabular-nums">{formatTemp(day.highC, units)}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`justify-self-end font-pixel text-xs text-stone transition-transform duration-150 ${isOpen ? "rotate-90 text-gold-bright" : ""}`}
|
||||
>
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && <DayDetails day={day} units={units} timeZone={timeZone} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{daily.length < 14 && (
|
||||
<p className={pixelLabel}>
|
||||
{providerLabel} gives {maxDaysOnPlan} days on this plan. Choose Open-Meteo for 14.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayDetails({ day, units, timeZone }: { day: DailyForecast; units: Units; timeZone: string | null }) {
|
||||
const wind =
|
||||
day.windKph === null
|
||||
? null
|
||||
: `${formatWind(day.windKph, units)}${day.windDeg !== null ? ` from ${compass(day.windDeg)}` : ""}${day.gustKph ? `, gusts ${formatWind(day.gustKph, units)}` : ""}`;
|
||||
|
||||
const facts: [string, string | null][] = [
|
||||
["Precipitation", day.precipMm !== null ? `${formatPrecip(day.precipMm, units)}${day.precipChance !== null ? ` (${day.precipChance}% chance)` : ""}` : null],
|
||||
["Wind", wind],
|
||||
["UV index", day.uvIndexMax !== null ? `${Math.round(day.uvIndexMax)} ${uvLabel(day.uvIndexMax)}` : null],
|
||||
["Humidity", day.humidity !== null ? `${Math.round(day.humidity)}%` : null],
|
||||
["Sunrise", day.sunrise ? formatClock(day.sunrise, timeZone) : null],
|
||||
["Sunset", day.sunset ? formatClock(day.sunset, timeZone) : null],
|
||||
["Daylight", day.sunrise && day.sunset ? formatDuration(day.sunrise, day.sunset) : null],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-t border-gold-dim/30 bg-black px-4 pt-3 pb-4">
|
||||
{day.summary && <p className="mb-2 first-letter:uppercase">{day.summary}</p>}
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-4">
|
||||
{facts
|
||||
.filter(([, value]) => value !== null)
|
||||
.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className={pixelLabel}>{label}</dt>
|
||||
<dd className="text-sm">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
components/forecast/ForecastPanel.tsx
Normal file
48
components/forecast/ForecastPanel.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import type { Units } from "@/lib/display";
|
||||
import { sectionHeading } from "@/lib/styles";
|
||||
import type { WeatherReport } from "@/lib/weather/types";
|
||||
import { ChoiceChips } from "../ui/ChoiceChips";
|
||||
import { DailyView } from "./DailyView";
|
||||
import { HourlyView } from "./HourlyView";
|
||||
|
||||
export type ForecastView = "hourly" | "daily";
|
||||
|
||||
/** The forecast section with a switch between the hourly and daily views. */
|
||||
export function ForecastPanel({ report, units, view, onViewChange, providerLabel, maxDaysOnPlan }: {
|
||||
report: WeatherReport;
|
||||
units: Units;
|
||||
view: ForecastView;
|
||||
onViewChange: (view: ForecastView) => void;
|
||||
providerLabel: string;
|
||||
maxDaysOnPlan: number;
|
||||
}) {
|
||||
return (
|
||||
<section aria-labelledby="forecast-heading" className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<h2 id="forecast-heading" className={sectionHeading}>
|
||||
The days ahead
|
||||
</h2>
|
||||
<span className="hidden h-px flex-1 bg-linear-to-r from-gold-dim/70 to-transparent sm:block" aria-hidden="true" />
|
||||
</div>
|
||||
<ChoiceChips
|
||||
legend="Forecast view"
|
||||
selected={view}
|
||||
onChange={onViewChange}
|
||||
choices={[
|
||||
{ value: "hourly", label: "Hourly" },
|
||||
{ value: "daily", label: "Daily", note: `${report.daily.length}d` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{view === "hourly" ? (
|
||||
<HourlyView hourly={report.hourly} stepHours={report.hourlyStepHours} units={units} timeZone={report.timeZone} />
|
||||
) : (
|
||||
<DailyView daily={report.daily} units={units} timeZone={report.timeZone} providerLabel={providerLabel} maxDaysOnPlan={maxDaysOnPlan} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
154
components/forecast/HourlyView.tsx
Normal file
154
components/forecast/HourlyView.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue