wayfarer-weather/lib/display.ts
Cayden Schwechten 6250d23297 init
2026-10-31 21:20:16 -05:00

186 lines
7.2 KiB
TypeScript
Raw 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 { CurrentWeather, HourlyForecast, Place } from "./weather/types";
/**
* Display helpers: unit conversion, time formatting, labels and small calculations.
* The API always speaks metric and UTC; everything here converts for the screen.
*/
export type Units = "imperial" | "metric";
// ---------- Units ----------
export function formatTemp(celsius: number, units: Units): string {
const value = units === "imperial" ? celsius * (9 / 5) + 32 : celsius;
return `${Math.round(value)}°`;
}
/** Temperature as a plain number in the chosen unit (for charts). */
export function tempValue(celsius: number, units: Units): number {
return units === "imperial" ? celsius * (9 / 5) + 32 : celsius;
}
export function formatWind(kph: number, units: Units): string {
return units === "imperial" ? `${Math.round(kph / 1.609)} mph` : `${Math.round(kph)} km/h`;
}
export function formatPressure(hpa: number, units: Units): string {
return units === "imperial" ? `${(hpa * 0.02953).toFixed(2)} inHg` : `${Math.round(hpa)} hPa`;
}
export function formatDistance(km: number, units: Units): string {
const value = units === "imperial" ? km / 1.609 : km;
const unit = units === "imperial" ? "mi" : "km";
return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${unit}`;
}
export function formatPrecip(mm: number, units: Units): string {
return units === "imperial" ? `${(mm / 25.4).toFixed(2)} in` : `${mm.toFixed(1)} mm`;
}
const COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
/** 225° → "SW". Wind direction is where the wind comes FROM. */
export function compass(degrees: number): string {
return COMPASS[Math.round(degrees / 22.5) % 16];
}
// ---------- Time (always shown in the location's time zone) ----------
/** Builds a formatter for the location's zone, falling back to the browser's. */
function inZone(timeZone: string | null, options: Intl.DateTimeFormatOptions) {
return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone ?? undefined });
}
/** "7:31 PM" */
export function formatClock(iso: string, timeZone: string | null): string {
return inZone(timeZone, { hour: "numeric", minute: "2-digit" }).format(new Date(iso));
}
/** "7 PM" */
export function formatHour(iso: string, timeZone: string | null): string {
return inZone(timeZone, { hour: "numeric" }).format(new Date(iso));
}
/** "Thu" */
export function formatWeekday(iso: string, timeZone: string | null): string {
return inZone(timeZone, { weekday: "short" }).format(new Date(iso));
}
/** "Oct 23" */
export function formatShortDate(iso: string, timeZone: string | null): string {
return inZone(timeZone, { month: "short", day: "numeric" }).format(new Date(iso));
}
/** "2026-10-23" — the local calendar day, for grouping and comparisons. */
export function localDayKey(iso: string, timeZone: string | null): string {
// Canadian English happens to format dates as YYYY-MM-DD.
return new Intl.DateTimeFormat("en-CA", { timeZone: timeZone ?? undefined }).format(new Date(iso));
}
/** "Today", "Tomorrow", or "Thu" */
export function dayName(iso: string, timeZone: string | null): string {
const today = localDayKey(new Date().toISOString(), timeZone);
const tomorrow = localDayKey(new Date(Date.now() + 86_400_000).toISOString(), timeZone);
const day = localDayKey(iso, timeZone);
if (day === today) return "Today";
if (day === tomorrow) return "Tomorrow";
return formatWeekday(iso, timeZone);
}
/** "10 h 41 m" between two instants. */
export function formatDuration(fromIso: string, toIso: string): string {
const minutes = Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60_000);
return `${Math.floor(minutes / 60)} h ${minutes % 60} m`;
}
export function placeLabel(place: Place): string {
const extra = place.region ?? place.country;
return extra ? `${place.name}, ${extra}` : place.name;
}
/** "47.61°N · 122.33°W" */
export function formatCoords(lat: number, lon: number, decimals = 2): string {
const ns = lat >= 0 ? "N" : "S";
const ew = lon >= 0 ? "E" : "W";
return `${Math.abs(lat).toFixed(decimals)}°${ns} · ${Math.abs(lon).toFixed(decimals)}°${ew}`;
}
// ---------- Labels for indexes ----------
/** UV index categories from the WHO / US EPA. */
export function uvLabel(uv: number): string {
if (uv < 3) return "Low";
if (uv < 6) return "Moderate";
if (uv < 8) return "High";
if (uv < 11) return "Very high";
return "Extreme";
}
/** US EPA AQI categories. */
export function aqiLabel(aqi: number): string {
if (aqi <= 50) return "Good";
if (aqi <= 100) return "Moderate";
if (aqi <= 150) return "Unhealthy for sensitive groups";
if (aqi <= 200) return "Unhealthy";
if (aqi <= 300) return "Very unhealthy";
return "Hazardous";
}
// ---------- Moon ----------
const SYNODIC_MONTH_DAYS = 29.530588853;
const KNOWN_NEW_MOON = Date.UTC(2000, 0, 6, 18, 14); // 6 Jan 2000, 18:14 UTC
const MOON_NAMES = [
"New moon", "Waxing crescent", "First quarter", "Waxing gibbous",
"Full moon", "Waning gibbous", "Last quarter", "Waning crescent",
];
/**
* Moon phase from simple arithmetic — accurate to within about a day,
* which is plenty for "what does the moon look like tonight".
* `fraction`: 0 = new, 0.5 = full, back to 1 = new.
*/
export function moonPhase(date = new Date()): { fraction: number; name: string; illumination: number } {
const daysSince = (date.getTime() - KNOWN_NEW_MOON) / 86_400_000;
const fraction = (((daysSince / SYNODIC_MONTH_DAYS) % 1) + 1) % 1;
const name = MOON_NAMES[Math.round(fraction * 8) % 8];
const illumination = Math.round(((1 - Math.cos(fraction * 2 * Math.PI)) / 2) * 100);
return { fraction, name, illumination };
}
// ---------- Small summaries ----------
/**
* One plain sentence about precipitation in the next 24 hours, e.g.
* "Rain likely around 9 PM (70%)." or "No precipitation expected in the next 24 hours."
*/
export function precipOutlook(hourly: HourlyForecast[], timeZone: string | null): string | null {
const next24 = hourly.filter((h) => Date.parse(h.time) < Date.now() + 24 * 3_600_000);
if (next24.length === 0 || next24.every((h) => h.precipChance === null)) return null;
const likely = next24.find((h) => (h.precipChance ?? 0) >= 50);
if (!likely) return "No precipitation expected in the next 24 hours.";
const kind = likely.condition === "snow" ? "Snow" : likely.condition === "storm" ? "Storms" : "Rain";
const isNow = likely === next24[0];
const when = isNow ? "now" : `around ${formatHour(likely.time, timeZone)}`;
return `${kind} likely ${when} (${likely.precipChance}%).`;
}
/**
* Comfort, out of 5 hearts (in half-heart steps).
* Start full, then lose hearts for being far from 21 °C, for wind, and for weather.
* It's a playful summary, not science — tweak the numbers to taste.
*/
export function comfortHearts(current: CurrentWeather): number {
let hearts = 5;
hearts -= Math.abs(current.feelsLikeC - 21) / 6; // −1 heart per 6 °C away from ideal
if (current.windKph > 25) hearts -= 1;
if (current.condition === "rain" || current.condition === "snow") hearts -= 1;
if (current.condition === "storm") hearts -= 2;
const clamped = Math.min(5, Math.max(0, hearts));
return Math.round(clamped * 2) / 2;
}