This commit is contained in:
Cayden Schwechten 2026-10-31 21:20:16 -05:00
commit 6250d23297
53 changed files with 6381 additions and 0 deletions

View file

@ -0,0 +1,128 @@
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>
);
}