128 lines
5.4 KiB
TypeScript
128 lines
5.4 KiB
TypeScript
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>
|
||
);
|
||
}
|