187 lines
7.2 KiB
TypeScript
187 lines
7.2 KiB
TypeScript
|
|
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;
|
|||
|
|
}
|