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

128 lines
5.4 KiB
TypeScript
Raw Permalink 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.

import type { ReactNode } from "react";
import {
aqiLabel, compass, formatClock, formatDistance, formatDuration, formatPrecip, formatPressure, formatTemp,
formatWind, moonPhase, uvLabel, type Units,
} from "@/lib/display";
import { pixelLabel, sectionHeading } from "@/lib/styles";
import type { WeatherReport } from "@/lib/weather/types";
import { Slot } from "./Slot";
/**
* Every current reading the provider gives us, one tile each.
* Tiles for readings the provider doesn't offer are simply left out.
*/
export function ConditionsGrid({ report, units }: { report: WeatherReport; units: Units }) {
const { current: c, airQuality: aq, timeZone } = report;
const today = report.daily[0];
const moon = moonPhase();
return (
<section aria-labelledby="conditions-heading" className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<h2 id="conditions-heading" className={sectionHeading}>
Current conditions
</h2>
<span className="h-px flex-1 bg-linear-to-r from-gold-dim/70 to-transparent" aria-hidden="true" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Tile label="Feels like" value={formatTemp(c.feelsLikeC, units)} note={c.dewPointC !== null ? `Dew point ${formatTemp(c.dewPointC, units)}` : undefined} />
<Tile label="Humidity" value={`${c.humidity}%`} meter={<Segments total={10} lit={Math.round(c.humidity / 10)} />} />
<Tile
label="Wind"
value={formatWind(c.windKph, units)}
note={[c.windDeg !== null && `From ${compass(c.windDeg)}`, c.gustKph && `gusts ${formatWind(c.gustKph, units)}`].filter(Boolean).join(", ") || undefined}
icon={c.windDeg !== null ? <WindArrow fromDegrees={c.windDeg} /> : undefined}
/>
{c.uvIndex !== null && (
<Tile
label="UV index"
value={String(Math.round(c.uvIndex))}
note={uvLabel(c.uvIndex)}
meter={<Segments total={11} lit={Math.min(11, Math.round(c.uvIndex))} />}
/>
)}
{aq && (
<Tile label="Air quality" value={`${aq.usAqi} AQI`} note={`${aqiLabel(aq.usAqi)}${aq.pm25 !== null ? ` · PM2.5 ${Math.round(aq.pm25)} µg/m³` : ""}`} />
)}
{c.pressureHpa !== null && <Tile label="Pressure" value={formatPressure(c.pressureHpa, units)} />}
{c.visibilityKm !== null && <Tile label="Visibility" value={formatDistance(c.visibilityKm, units)} />}
{c.cloudCover !== null && <Tile label="Cloud cover" value={`${Math.round(c.cloudCover)}%`} />}
{c.precipMm !== null && <Tile label="Precipitation" value={formatPrecip(c.precipMm, units)} note="In the last hour" />}
{today?.sunrise && today.sunset && (
<Tile
label="Sun"
value={`${formatClock(today.sunrise, timeZone)} – ${formatClock(today.sunset, timeZone)}`}
note={`${formatDuration(today.sunrise, today.sunset)} of daylight`}
/>
)}
<Tile label="Moon" value={moon.name} note={`${moon.illumination}% lit`} icon={<MoonIcon fraction={moon.fraction} />} />
</div>
</section>
);
}
/**
* One reading. `icon` sits beside the value; `meter` sits underneath it.
*/
function Tile({ label, value, note, icon, meter }: { label: string; value: string; note?: string; icon?: ReactNode; meter?: ReactNode }) {
return (
<Slot className="[--cut:10px]" innerClassName="flex flex-col gap-1 p-3 sm:px-4">
<p className={pixelLabel}>{label}</p>
<div className="flex items-center justify-between gap-2">
<p className="font-display text-lg font-medium sm:text-xl">{value}</p>
{icon}
</div>
{note && <p className="text-xs text-stone first-letter:uppercase">{note}</p>}
{meter}
</Slot>
);
}
/** A row of lit/unlit segments, like a stamina gauge. */
function Segments({ total, lit }: { total: number; lit: number }) {
return (
<span className="mt-1 flex gap-[3px]" aria-hidden="true">
{Array.from({ length: total }, (_, i) => (
<span key={i} className={`h-1.5 flex-1 ${i < lit ? "bg-teal" : "bg-teal-off"}`} />
))}
</span>
);
}
/** An arrow pointing where the wind is blowing TO (opposite of where it comes from). */
function WindArrow({ fromDegrees }: { fromDegrees: number }) {
return (
<svg width={28} height={28} viewBox="-10 -10 20 20" aria-hidden="true" style={{ transform: `rotate(${fromDegrees + 180}deg)` }}>
<path d="M0,-8 L5,4 L0,1 L-5,4 Z" fill="var(--color-gold-bright)" />
</svg>
);
}
/**
* The moon drawn from its phase: a dark disc, with the lit part outlined by
* an outer half-circle and a terminator ellipse whose width follows the phase.
*/
function MoonIcon({ fraction }: { fraction: number }) {
const r = 9;
const waxing = fraction < 0.5;
const terminatorWidth = r * Math.abs(Math.cos(fraction * 2 * Math.PI));
// The terminator bulges toward the lit side for crescents and away from it for gibbous moons.
const isCrescent = fraction < 0.25 || fraction > 0.75;
const outerSweep = waxing ? 1 : 0;
const innerSweep = isCrescent === waxing ? 0 : 1;
const lit = `M0,${-r} A${r},${r} 0 0 ${outerSweep} 0,${r} A${terminatorWidth},${r} 0 0 ${innerSweep} 0,${-r} Z`;
return (
<svg width={28} height={28} viewBox="-10 -10 20 20" aria-hidden="true">
<circle r={r} fill="var(--color-teal-off)" />
<path d={lit} fill="var(--color-ink)" />
</svg>
);
}