import { getMoonIllumination, getMoonTimes, getTimes } from "./suncalc.js";
import { lineLoader } from "./data_loaders.js";
import { logger } from "./utils.js";
// Weather data is a CSV export of 2020-2023 data from
// https://open-meteo.com/en/docs/historical-weather-api?daily=weather_code%2Cwind_speed_10m_max%2Cwind_gusts_10m_max%2Csnowfall_sum%2Crain_sum%2Capparent_temperature_max%2Capparent_temperature_min&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&start_date=2023-01-01&end_date=2023-12-31&bounding_box=-90%2C-180%2C90%2C180&hourly&latitude=35.4025&longitude=-99.4462&timezone=America%2FDenver#hourly_weather_variables
// The link encodes the fields and their order: weather code, max apparent temperature, min apparent temperature, max wind
// speed, max wind gusts, rain sum, snowfall sum. The query parameters may be dictating the order which is not the order given
// here (see the nameArray function below)
const ONE_YEAR_DATA = await lineLoader("weather.data");
// somewhere in Oklahoma, because the weather is more dramatic there than to the west
const LAT = 35.4025;
const LONG = -99.4462;
const WMO_CODES = {
0: "Clear skies",
1: "Just a few clouds in the sky",
2: "Clear skies",
3: "Clouds in the sky",
4: "Visibility reduced by fire smoke",
5: "Haze",
6: "Widespread dust in the air",
7: "Dust or sand raised by wind",
8: "Well developed dust whirls",
9: "Duststorms in the distance",
10: "Mist",
11: "Patchy fog in the morning",
12: "Patchy fog",
13: "Lightning visible, no thunder heard",
14: "Precipitation within sight in the sky, not reaching the ground",
15: "Precipitation within sight, reaching the ground in the distance",
16: "Precipitation within sight, reaching the ground near location",
17: "Thunderstorm, but no rain",
18: "Squalls",
19: "Tornadoes",
20: "Drizzle (not freezing) or snow grains not falling as shower(s)",
21: "Rain (not freezing) not falling as shower(s)",
22: "Snow not falling as shower(s)",
23: "Rain and snow or ice pellets not falling as shower(s)",
24: "Freezing drizzle or freezing rain not falling as shower(s)",
25: "Shower(s) of rain",
26: "Shower(s) of snow, or of rain and snow",
27: "Shower(s) of hail*, or of rain and hail",
28: "Fog or ice fog",
29: "Thunderstorm (with or without precipitation)",
30: "Slight or moderate duststorm or sandstorm - has decreased during the preceding hour",
31: "Slight or moderate duststorm or sandstorm - no appreciable change during the preceding hour",
32: "Slight or moderate duststorm or sandstorm - has begun or has increased during the preceding hour",
33: "Severe duststorm or sandstorm - has decreased during the preceding hour",
34: "Severe duststorm or sandstorm- no appreciable change during the preceding hour",
35: "Severe duststorm or sandstorm- has begun or has increased during the preceding hour",
36: "Slight or moderate blowing snow generally low (below eye level)",
37: "Heavy drifting snow generally low (below eye level)",
38: "Slight or moderate blowing snow generally high (above eye level)",
39: "Heavy drifting snow generally high (above eye level)",
40: "Fog or ice fog at a distance at the time of observation, but not at the station during the preceding hour, the fog or ice fog extending to a level above that of the observer",
41: "Fog or ice fog in patches",
42: "Fog or ice fog, sky visible, has become thinner during the preceding hour",
43: "Fog or ice fog, sky invisible, has become thinner during the preceding hour",
44: "Fog or ice fog, sky visible, no appreciable change during the preceding hour",
45: "Fog or ice fog, sky invisible, no appreciable change during the preceding hour",
46: "Fog or ice fog, sky visible, has begun or has become thicker during the preceding hour",
47: "Fog or ice fog, sky invisible, has begun or has become thicker during the preceding hour",
48: "Fog, depositing rime, sky visible",
49: "Fog, depositing rime, sky invisible",
50: "Light drizzle",
51: "Light drizzle",
52: "Moderate drizzle",
53: "Moderate drizzle",
54: "Heavy drizzle",
55: "Heavy drizzle",
56: "Light freezing drizzle",
57: "Freezing drizzle",
58: "Light drizzle and rain",
59: "Drizzle and rain",
60: "Light rain",
61: "Light rain",
62: "Moderate rain",
63: "Moderate rain",
64: "Heavy rain",
65: "Heavy rain",
66: "Slight freezing rain",
67: "Freezing rain",
68: "Rain or drizzle and snow, slight",
69: "Rain or drizzle and snow, moderate or heavy",
70: "Light snowfall",
71: "Light snowfall",
72: "Moderate snowfall",
73: "Moderate snowfall",
74: "Heavy snowfall",
75: "Heavy snowfall",
76: "Diamond dust (with or without fog)",
77: "Snow grains (with or without fog)",
78: "Isolated star-like snow crystals (with or without fog)",
79: "Ice pellets",
80: "Rain shower(s), slight",
81: "Rain shower(s), moderate or heavy",
82: "Rain shower(s), violent",
83: "Shower(s) of rain and snow mixed, slight",
84: "Shower(s) of rain and snow mixed, moderate or heavy",
85: "Snow shower(s), slight",
86: "Snow shower(s), moderate or heavy",
87: "Shower(s) of snow pellets or small hail, with or without rain or rain and snow mixed - slight",
88: "Shower(s) of snow pellets or small hail, with or without rain or rain and snow mixed - moderate or heavy",
89: "Shower(s) of hail*, with or without rain or rain and snow mixed, not associated with thunder - slight",
90: "Shower(s) of hail*, with or without rain or rain and snow mixed, not associated with thunder - moderate or heavy",
91: "Slight rain at time of observation, Thunderstorm during the preceding hour but not at time of observation",
92: "Moderate or heavy rain at time of observation",
93: "Slight snow, or rain and snow mixed or hail** at time of observation",
94: "Moderate or heavy snow, or rain and snow mixed or hail** at time of observation",
95: "Thunderstorm, slight or moderate, without hail** but with rain and/or snow at time of observation Thunderstorm at time of observation",
96: "Thunderstorm, slight or moderate, with hail** at time of observation",
97: "Thunderstorm, heavy, without hail** but with rain and/or snow at time of observation",
98: "Thunderstorm combined with duststorm or sandstorm at time of observation",
99: "Thunderstorm, heavy, with hail** at time of observation",
};
const MOONS = [
{ icon: "π", text: "new moon", light: "dark" },
{ icon: "π", text: "waxing crescent moon", light: "dark" },
{ icon: "π", text: "first quarter moon", light: "dim light" },
{ icon: "π", text: "waxing gibbous moon", light: "light" },
{ icon: "π", text: "full moon", light: "light" },
{ icon: "π", text: "waning gibbous moon", light: "light" },
{ icon: "π", text: "last quarter moon", light: "dim light" },
{ icon: "π", text: "waning crescent moon", light: "dark" },
];
function toMoon(date) {
const phase = getMoonIllumination(date).phase;
return MOONS[Math.round(7 * phase)];
}
const WEATHER_HISTORY = new Map();
ONE_YEAR_DATA.map((line) => line.split(","))
.map(nameArray)
.forEach((obj) => WEATHER_HISTORY.set(obj.time, obj));
function nameArray(arr) {
return {
time: arr[0],
sky: WMO_CODES[arr[1]] + " today",
tempMin: parseFloat(arr[7]),
tempMax: parseFloat(arr[6]),
windMax: parseFloat(arr[2]),
windGusts: parseFloat(arr[3]),
snowfall: parseFloat(arr[4]),
rain: parseFloat(arr[5]),
};
}
const FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Denver",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
function getLocalTime(now) {
const parts = FORMATTER.formatToParts(now);
return `${parts[0].value}:${parts[2].value}`;
}
function boundedBySunset(sunset, moonrise) {
return !moonrise || moonrise.getTime() < sunset.getTime() ? sunset : moonrise;
}
function boundedBySunrise(sunrise, moonset) {
return !moonset || moonset.getTime() > sunrise.getTime() ? sunrise : moonset;
}
/**
* A weather report for a single day, including temperature, wind, precipitation,
* sky conditions, sun/moon timing, and gameplay effect warnings.
*
* @property {number} tempMin - Minimum temperature in Β°F (accounts for windchill).
* @property {number} tempMax - Maximum temperature in Β°F (accounts for windchill).
* @property {number} windMax - Maximum sustained wind speed in mph.
* @property {number} windGusts - Maximum wind gust speed in mph.
* @property {number} rain - Total rainfall in inches.
* @property {number} snowfall - Total snowfall in inches.
* @property {string} sky - WMO weather code description of sky conditions.
* @property {string} dawn - Dawn time in HH:MM format (local time).
* @property {string} sunrise - Sunrise time in HH:MM format (local time).
* @property {string} sunset - Sunset time in HH:MM format (local time).
* @property {string} dusk - Dusk time in HH:MM format (local time).
* @property {string} moonPhase - Moon phase emoji icon.
* @property {string} moonText - Moon phase description (e.g. "full moon (bright at night)").
* @property {string} moonLight - Moon illumination level description.
* @property {string} moonRise - Effective moonrise time in HH:MM format, bounded by dusk.
* @property {string} moonSet - Effective moonset time in HH:MM format, bounded by dawn.
*/
class Weather {
constructor({
tempMin,
tempMax,
windMax,
windGusts,
rain,
snowfall,
sky,
dawn,
sunrise,
sunset,
dusk,
moonPhase,
moonText,
moonLight,
moonRise,
moonSet,
} = {}) {
this.tempMin = tempMin;
this.tempMax = tempMax;
this.windMax = windMax;
this.windGusts = windGusts;
this.rain = rain;
this.snowfall = snowfall;
this.sky = sky;
this.dawn = dawn;
this.sunrise = sunrise;
this.sunset = sunset;
this.dusk = dusk;
this.moonPhase = moonPhase;
this.moonText = moonText;
this.moonLight = moonLight;
this.moonRise = moonRise;
this.moonSet = moonSet;
}
toString() {
let warning = [];
if (this.tempMin < 32) {
warning.push("Cold weather gear required to avoid frostbite or death");
} else if (this.tempMin < 45) {
warning.push("Cold weather gear required to avoid exhaustion");
} else if (this.tempMax > 97) {
warning.push(
"Extra water rations, shade, and inactivity at the height of the heat are needed to avoid heat stroke, heat exhaustion, or death",
);
} else if (this.tempMax > 80) {
warning.push(
"Warm weather limits strenuous outdoor activity and requires extra water rations to avoid exhaustion",
);
}
if (this.snowfall > 1) {
warning.push("Snow limits travel today");
}
if (warning.length === 0) {
warning.push("Nothing about the weather is dangerous or impedes travel today");
}
const arr = [];
arr.push(["Temp", `low ${this.tempMin}Β° to high ${this.tempMax}Β° (including windchill)`]);
arr.push(["Wind", `${this.windMax} to ${this.windGusts} mph`]);
// sky stuff
const a = [this.sky];
if (this.rain > 0.01) {
a.push(`rain ${this.rain} in`);
}
if (this.snowfall > 0.01) {
a.push(`snowfall ${this.snowfall} in`);
}
arr.push(["Sky", a.join(", ")]);
arr.push([
"Sun",
`Light from ${this.dawn} to ${this.dusk} (sunrise ${this.sunrise}, sunset ${this.sunset})`,
]);
// but if itβs dark out, moon rise and moon set donβt matter
arr.push([
"Moon",
`${this.moonPhase} ${this.moonText} (${this.moonLight} from ${this.moonRise} to ${this.moonSet})`,
]);
if (warning.length) {
arr.push(["Effects", warning.join(". ")]);
}
// get wind chill information, it's more significant
return "<p>" + arr.map((arr) => `<b>${arr[0]}:</b> ${arr[1]}`).join(". ") + ".</p>";
}
}
/**
* Creates a weather report for a given date and location, including temperature, wind,
* precipitation, sun/moon times, and game-relevant effects (cold/heat warnings, travel impacts).
*
* @param {Object} [options={}] - Weather creation options
* @param {string} [options.date] - ISO 8601 date string (e.g. "2026-03-10"). If not provided,
* defaults to today's date.
* @param {number} [options.lat=38.24475] - Latitude for sun/moon calculations.
* @param {number} [options.long=-98.657227] - Longitude for sun/moon calculations.
* @returns {Weather} A Weather instance with temperature, wind, precipitation, sky conditions,
* sun times, moon phase, and gameplay effect warnings.
*/
export function createWeather({ date, lat = LAT, long = LONG } = {}) {
logger.start("createWeather", { date, lat, long });
const dateOnly = date ? date : new Date().toISOString().split("T")[0]; // 2026-01-02
const yearNum = parseInt(dateOnly.split("-")[0].substring(3)) % 4; // 0, 1, 2, 3
const day = dateOnly.substring(5) !== "02-29" ? dateOnly.substring(5) : "02-28"; // 01-02
const astroTime = new Date(`199${yearNum}-${day}T12:00:00.000-06:00`);
const times = getTimes(astroTime, lat, long);
const moonTimes = getMoonTimes(astroTime, lat, long);
const dawn = getLocalTime(times.dawn);
const sunrise = getLocalTime(times.sunrise);
const sunset = getLocalTime(times.sunset);
const dusk = getLocalTime(times.dusk);
const moon = toMoon(astroTime);
const moonPhase = moon.icon;
const moonText = moon.text;
const moonLight = moon.light;
const moonRise = getLocalTime(boundedBySunset(times.dusk, moonTimes.rise));
const moonSet = getLocalTime(boundedBySunrise(times.dawn, moonTimes.set));
const weather = WEATHER_HISTORY.get(`${yearNum}-${day}`);
return logger.end(
new Weather({
...weather,
dawn,
sunrise,
sunset,
dusk,
moonPhase,
moonText,
moonLight,
moonRise,
moonSet,
}),
);
}