create_weather.js

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,
    }),
  );
}