/* ============================================================================
   wc-data.jsx — DATA LAYER for the 2026 World Cup Heat Stress dashboard.

   ▸ This file is the single place to swap synthetic data for live feeds.
     Replace buildForecast() / buildHistory() with calls to your API and the
     rest of the UI keeps working unchanged — they just need to return arrays
     in the documented shapes (see the comments above each function).

   ▸ Everything is stored internally in °F. Display-time unit conversion
     (°F / °C) happens in the formatting helpers only.
   ============================================================================ */

/* ──────────────────────────────────────────────────────────────────────────
   METRIC CONFIG — WBGT and Heat Index thresholds (°F), 5-tier risk model.
   tiers:  0 Low · 1 / 2 / 3 · 4 Extreme   (color ramp green→yellow→orange→red→black)
   thresholds = the 4 boundaries between the 5 tiers.
   ────────────────────────────────────────────────────────────────────────── */
const TIER_COLORS  = ['#5EC269', '#E0E579', '#D6953A', '#EC7569', '#0E0E12'];
const TIER_TONES   = ['green', 'yellow', 'orange', 'red', 'black'];

const METRICS = {
  wbgt: {
    key: 'wbgt',
    label: 'WBGT',
    full: 'Wet Bulb Globe Temp',
    scaleMin: 60,
    scaleMax: 100,
    thresholds: [78, 82, 86, 90],
    tierLabels: ['Low', 'Moderate', 'High', 'Very High', 'Extreme'],
    // short ranges shown under the threshold gauge (°F)
    rangeLabels: ['<78°', '78–82°', '82–86°', '86–90°', '>90°'],
    axisTicks: [70, 78, 86, 94],
  },
  heatIndex: {
    key: 'heatIndex',
    label: 'Heat Index',
    full: 'Heat Index',
    scaleMin: 60,
    scaleMax: 130,
    thresholds: [80, 90, 103, 124],
    tierLabels: ['Low', 'Caution', 'Ext. Caution', 'Danger', 'Ext. Danger'],
    rangeLabels: ['<80°', '80–90°', '90–103°', '103–124°', '>124°'],
    axisTicks: [75, 90, 105, 120],
  },
  ambient: {
    key: 'ambient',
    label: 'Ambient Temp',
    full: 'Ambient Temperature',
    scaleMin: 50,
    scaleMax: 115,
    thresholds: [85, 92, 99, 106],
    tierLabels: ['Mild', 'Warm', 'Hot', 'Very Hot', 'Extreme'],
    rangeLabels: ['<85°', '85–92°', '92–99°', '99–106°', '>106°'],
    axisTicks: [70, 85, 100, 110],
  },
};

/* ──────────────────────────────────────────────────────────────────────────
   ROOF MODEL — how a venue's structure lowers effective heat stress on the
   field of play. Effective reading = ambient − reduction.
   ────────────────────────────────────────────────────────────────────────── */
const ROOFS = {
  open:        { label: 'Open air',            short: 'Open',        wbgt: 0,  heatIndex: 0,  ambient: 0 },
  partial:     { label: 'Roof over stands',    short: 'Partial roof',wbgt: 2,  heatIndex: 2,  ambient: 1 },
  canopy:      { label: 'Shade canopy',        short: 'Canopy',      wbgt: 3,  heatIndex: 4,  ambient: 2 },
  retractable: { label: 'Retractable · A/C',   short: 'Climate-controlled', wbgt: 9, heatIndex: 12, ambient: 7 },
};

/* ──────────────────────────────────────────────────────────────────────────
   STADIUMS — the 16 host venues. `climate` holds peak/min daily readings (°F)
   for the current tournament window; these drive the synthetic generators and
   are exactly what you'd replace with observed/forecast values from a feed.
   nextMatch.kickoff = local hour (0–23); day = days from today (0 = Sat Jun 20).
   ────────────────────────────────────────────────────────────────────────── */
const STADIUMS = [
  // ── UNITED STATES ──────────────────────────────────────────────────────
  { id: 'hou', city: 'Houston', venue: 'Houston Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'retractable', capacity: 72220, tz: 'CT',
    climate: { wbgt: [78, 90], heatIndex: [86, 108], ambient: [82, 99] },
    nextMatch: { group: 'Group F', matchday: 'Matchday 2', teams: 'Netherlands vs Sweden', date: 'Sat Jun 20', day: 0, kickoff: 12 } },
  { id: 'dal', city: 'Dallas', venue: 'Dallas Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'retractable', capacity: 80000, tz: 'CT',
    climate: { wbgt: [76, 88], heatIndex: [82, 104], ambient: [79, 96] },
    nextMatch: { group: 'Group J', matchday: 'Matchday 2', teams: 'Argentina vs Austria', date: 'Mon Jun 22', day: 2, kickoff: 12 } },
  { id: 'mia', city: 'Miami', venue: 'Miami Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'canopy', capacity: 65326, tz: 'ET',
    climate: { wbgt: [79, 88], heatIndex: [88, 106], ambient: [84, 97] },
    nextMatch: { group: 'Group H', matchday: 'Matchday 2', teams: 'Uruguay vs Cape Verde', date: 'Sun Jun 21', day: 1, kickoff: 18 } },
  { id: 'kan', city: 'Kansas City', venue: 'Kansas City Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'open', capacity: 76416, tz: 'CT',
    climate: { wbgt: [72, 85], heatIndex: [80, 100], ambient: [76, 93] },
    nextMatch: { group: 'Group E', matchday: 'Matchday 2', teams: 'Ecuador vs Curaçao', date: 'Sat Jun 20', day: 0, kickoff: 19 } },
  { id: 'atl', city: 'Atlanta', venue: 'Atlanta Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'retractable', capacity: 71000, tz: 'ET',
    climate: { wbgt: [73, 84], heatIndex: [80, 98], ambient: [77, 91] },
    nextMatch: { group: 'Group H', matchday: 'Matchday 2', teams: 'Spain vs Saudi Arabia', date: 'Sun Jun 21', day: 1, kickoff: 12 } },
  { id: 'phi', city: 'Philadelphia', venue: 'Philadelphia Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'open', capacity: 69596, tz: 'ET',
    climate: { wbgt: [70, 82], heatIndex: [76, 95], ambient: [73, 89] },
    nextMatch: { group: 'Group I', matchday: 'Matchday 2', teams: 'France vs Iraq', date: 'Mon Jun 22', day: 2, kickoff: 17 } },
  { id: 'nyc', city: 'New York / NJ', venue: 'New York New Jersey Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'open', capacity: 82500, tz: 'ET',
    climate: { wbgt: [69, 81], heatIndex: [74, 93], ambient: [72, 87] },
    nextMatch: { group: 'Group I', matchday: 'Matchday 2', teams: 'Norway vs Senegal', date: 'Mon Jun 22', day: 2, kickoff: 20 } },
  { id: 'lax', city: 'Los Angeles', venue: 'Los Angeles Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'canopy', capacity: 70240, tz: 'PT',
    climate: { wbgt: [66, 77], heatIndex: [72, 85], ambient: [69, 81] },
    nextMatch: { group: 'Group G', matchday: 'Matchday 2', teams: 'Belgium vs Iran', date: 'Sun Jun 21', day: 1, kickoff: 12 } },
  { id: 'sfo', city: 'San Francisco Bay', venue: 'San Francisco Bay Area Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'open', capacity: 68500, tz: 'PT',
    climate: { wbgt: [60, 74], heatIndex: [64, 80], ambient: [62, 77] },
    nextMatch: { group: 'Group J', matchday: 'Matchday 2', teams: 'Jordan vs Algeria', date: 'Mon Jun 22', day: 2, kickoff: 20 } },
  { id: 'bos', city: 'Boston', venue: 'Boston Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'open', capacity: 65878, tz: 'ET',
    climate: { wbgt: [62, 75], heatIndex: [66, 83], ambient: [64, 79] },
    nextMatch: { group: 'Group L', matchday: 'Matchday 2', teams: 'England vs Ghana', date: 'Tue Jun 23', day: 3, kickoff: 16 } },
  { id: 'sea', city: 'Seattle', venue: 'Seattle Stadium', country: 'United States', cc: 'US', flag: '🇺🇸',
    roof: 'partial', capacity: 68740, tz: 'PT',
    climate: { wbgt: [58, 70], heatIndex: [60, 75], ambient: [59, 73] },
    nextMatch: { group: 'Group B', matchday: 'Matchday 3', teams: 'Bosnia & Herzegovina vs Qatar', date: 'Wed Jun 24', day: 4, kickoff: 12 } },

  // ── CANADA ─────────────────────────────────────────────────────────────
  { id: 'tor', city: 'Toronto', venue: 'Toronto Stadium', country: 'Canada', cc: 'CA', flag: '🇨🇦',
    roof: 'open', capacity: 45736, tz: 'ET',
    climate: { wbgt: [63, 76], heatIndex: [67, 84], ambient: [65, 80] },
    nextMatch: { group: 'Group E', matchday: 'Matchday 2', teams: 'Germany vs Ivory Coast', date: 'Sat Jun 20', day: 0, kickoff: 16 } },
  { id: 'van', city: 'Vancouver', venue: 'BC Place Vancouver', country: 'Canada', cc: 'CA', flag: '🇨🇦',
    roof: 'retractable', capacity: 54500, tz: 'PT',
    climate: { wbgt: [57, 70], heatIndex: [59, 74], ambient: [58, 72] },
    nextMatch: { group: 'Group G', matchday: 'Matchday 2', teams: 'New Zealand vs Egypt', date: 'Sun Jun 21', day: 1, kickoff: 18 } },

  // ── MEXICO ─────────────────────────────────────────────────────────────
  { id: 'mty', city: 'Monterrey', venue: 'Monterrey Stadium', country: 'Mexico', cc: 'MX', flag: '🇲🇽',
    roof: 'open', capacity: 53500, tz: 'CT',
    climate: { wbgt: [76, 89], heatIndex: [84, 110], ambient: [80, 100] },
    nextMatch: { group: 'Group F', matchday: 'Matchday 2', teams: 'Tunisia vs Japan', date: 'Sat Jun 20', day: 0, kickoff: 22 } },
  { id: 'gdl', city: 'Guadalajara', venue: 'Guadalajara Stadium', country: 'Mexico', cc: 'MX', flag: '🇲🇽',
    roof: 'partial', capacity: 48071, tz: 'CT',
    climate: { wbgt: [66, 80], heatIndex: [72, 91], ambient: [69, 86] },
    nextMatch: { group: 'Group K', matchday: 'Matchday 2', teams: 'Colombia vs DR Congo', date: 'Tue Jun 23', day: 3, kickoff: 20 } },
  { id: 'mex', city: 'Mexico City', venue: 'Mexico City Stadium', country: 'Mexico', cc: 'MX', flag: '🇲🇽',
    roof: 'open', capacity: 83264, tz: 'CT',
    climate: { wbgt: [55, 72], heatIndex: [58, 76], ambient: [57, 74] },
    nextMatch: { group: 'Group A', matchday: 'Matchday 3', teams: 'Czechia vs Mexico', date: 'Wed Jun 24', day: 4, kickoff: 19 } },
];

const COUNTRY_ORDER = ['United States', 'Canada', 'Mexico'];

/* ── Perry Weather station coverage ──────────────────────────────────
   Number of PW weather stations the HISTORICAL data is aggregated from
   (historical values = average of max readings across these stations).
   Populated automatically from data/worldcup-heat.csv at load (median of the
   per-reading Stations column). null = venue not in the feed → the UI shows
   a “modeled estimate” note instead. */
const PW_STATIONS = {
  hou: null, dal: null, mia: null, kan: null, atl: null, phi: null, nyc: null, lax: null, sfo: null, bos: null, sea: null,
  tor: null, van: null,
  mty: null, gdl: null, mex: null,
};

/* Hardcoded stadium photos — files under assets/stadiums/. As more images are
   uploaded, save them as assets/stadiums/<id>.png and add the id here. Cards
   fall back to a drag-and-drop slot for ids not listed. */
/* bundler resource indirection: the standalone export inlines these via
   <meta name="ext-resource-dependency"> tags; live, the fallback path is used. */
function RES(id, fallback) { return (window.__resources && window.__resources[id]) || fallback; }
window.RES = RES;

const STADIUM_PHOTOS = {
  hou: RES('ph_hou', 'assets/stadiums/hou.jpg'),
  dal: RES('ph_dal', 'assets/stadiums/dal.png'),
  mia: RES('ph_mia', 'assets/stadiums/mia.jpg'),
  kan: RES('ph_kan', 'assets/stadiums/kan.png'),
  atl: RES('ph_atl', 'assets/stadiums/atl.jpg'),
  phi: RES('ph_phi', 'assets/stadiums/phi.webp'),
  nyc: RES('ph_nyc', 'assets/stadiums/nyc.jpg'),
  lax: RES('ph_lax', 'assets/stadiums/lax.jpg'),
  sfo: RES('ph_sfo', 'assets/stadiums/sfo.jpg'),
  bos: RES('ph_bos', 'assets/stadiums/bos.jpg'),
  sea: RES('ph_sea', 'assets/stadiums/sea.jpg'),
  tor: RES('ph_tor', 'assets/stadiums/tor.jpg'),
  van: RES('ph_van', 'assets/stadiums/van.webp'),
  mty: RES('ph_mty', 'assets/stadiums/mty.jpg'),
  gdl: RES('ph_gdl', 'assets/stadiums/gdl.jpg'),
  mex: RES('ph_mex', 'assets/stadiums/mex.png'),
};

/* ──────────────────────────────────────────────────────────────────────────
   PERSONAS — WC2026 alert bands per population category.
   Source: WC2026_Spectators / WC2026_Volunteers / WC2026_Workers sheets
   (Korey Stringer Institute · June 2026). All three audiences share the same
   GREEN→YELLOW→ORANGE→RED→BLACK band boundaries; what differs is the
   activity guidance and work-rest protocol.
   thresholds[metric] = the 4 °F boundaries between the 5 bands:
     WBGT:       <82 / 82–84.9 / 85–87.9 / 88–89.9 / ≥90
     Ambient °F: approx <91 / 91–94 / 94–97 / 97–99 / >99
     Heat Index: approx <99 / 99–102 / 102–105 / 105–107 / >107
   ────────────────────────────────────────────────────────────────────────── */
const WC_BANDS = { wbgt: [82, 85, 88, 90], ambient: [91, 94, 97, 99], heatIndex: [99, 102, 105, 107] };

const PERSONAS = {
  spectators: {
    key: 'spectators', label: 'Spectators', sub: 'Outdoor attendance',
    thresholds: WC_BANDS,
    tierLabels: ['No restrictions', 'Caution', 'Moderate caution', 'High caution', 'Extreme'],
    guidance: [
      'No restrictions. Normal outdoor attendance.\nDrink water regularly (8 oz every 20–30 min). Wear light clothing and sunscreen. Know the location of water stations and cooling areas.',
      'Outdoor attendance with caution. Limit prolonged standing in direct sun.\nSeek shade when not in seats. Drink water every 15–20 min. Wet clothing or misting can help. Know location of nearest cooling station. Vulnerable populations should move to shaded or cooled areas.',
      'Moderate caution. Reduce time in direct sun. Prioritize shaded or air-conditioned areas.\nMove to shaded seating or indoor areas when possible. Drink 1 cup water every 15 min — do not wait for thirst. Cool neck and wrists with cold water or wet cloth. Identify and visit nearest cooling center. Limit moderate and high physical exertion. Vulnerable populations should move to shaded or cooled areas.',
      'High caution. Utilize air-conditioned areas. Limit outdoor time if possible.\nUtilize air-conditioned concourses, clubs, or designated cooling zones as much as possible. Drink water continuously. If feeling unwell: stop, move to a cool area immediately, and notify event staff. Vulnerable populations should move to shaded or cooled areas.',
      'Extreme conditions. All spectators advised to find air-conditioned indoor areas.\nFind air-conditioned areas or cooling centers. Drink water and rest in cool environment. Report any symptoms: dizziness, confusion, fainting — to medical staff immediately. Vulnerable populations should move to shaded or cooled areas.',
    ],
  },
  volunteers: {
    key: 'volunteers', label: 'Volunteers', sub: 'Easy–moderate work',
    thresholds: WC_BANDS,
    tierLabels: ['60/0 work-rest', '50/10 work-rest', '40/20 work-rest', '30/30 work-rest', '20/40 work-rest'],
    guidance: [
      'Work/rest 60/0 — no limit to work time per hour.\nStart workday hydrated and consume fluids regularly throughout work shift. Normal volunteer duties. Shade and water should be available at all volunteer positions. Buddy system recommended.',
      'Work/rest 50/10 — 50 min work / 10 min rest per hour for moderate work (no limit for easy work).\nRest should be in shade or cooled area. Utilize buddy system. Brief all volunteers on early heat illness symptoms (dizziness, nausea). Unacclimatized volunteers should reduce work intensity. Supervisor checks every 30 minutes. Start workday hydrated, and monitor hydration status (i.e., urine color, urine frequency, thirst, daily body weight changes) to dictate fluid consumption. Consider drinking approximately 8 oz water every 20–30 min during work. Electrolyte drinks required for shifts > 2 hours. Avoid alcohol and caffeine.',
      'Work/rest 40/20 — 40 min work / 20 min rest per hour for moderate work (no limit for easy work).\nAll rest should be in shade or air-conditioned area. Rotate volunteers to limit individual exposure. Limit outdoor shift lengths to 2 hours maximum. Consider relocating volunteers to shaded or indoor positions where available. Supervisor on site at all times. Start workday hydrated, and monitor hydration status (i.e., urine color, urine frequency, thirst, daily body weight changes) to dictate fluid consumption. Consider drinking approximately 8 oz water every 15–20 min during work. Electrolyte drinks required for shifts > 2 hours. Avoid alcohol and caffeine.',
      'Work/rest 30/30 — 30 min work / 30 min rest per hour for moderate work (no limit for easy work).\nNon-essential outdoor volunteer duties should be moved indoors. Volunteers with known health conditions or limited acclimatization should be moved to cooled indoor assignments. Buddy system recommended. Supervisor on site at all times. Start workday hydrated, and monitor hydration status (i.e., urine color, urine frequency, thirst, daily body weight changes) to dictate fluid consumption. Consider drinking approximately 8 oz water every 15 min during work. Electrolyte drinks required for shifts > 2 hours. Avoid alcohol and caffeine.',
      'Work/rest 20/40 — 20 min work / 40 min rest per hour for moderate work (50/10 for easy work).\nRelocate outdoor volunteers to air-conditioned indoor roles. Medical check for any volunteer showing symptoms before dismissal. Buddy system recommended. Supervisor on site at all times. All volunteers should hydrate in cool areas. Monitor for symptoms during and after duty.',
    ],
  },
  workers: {
    key: 'workers', label: 'Workers', sub: 'Occupational work',
    thresholds: WC_BANDS,
    tierLabels: ['60/0 work-rest', '50/10 work-rest', '40/20 work-rest', '30/30 work-rest', '20/40 work-rest'],
    guidance: [
      'Work/rest 60/0 — continuous work permitted.\nHydration: start the workday hydrated and consume fluids regularly throughout the shift.\nNormal operations. Buddy system recommended. Ensure shade and water access are available at the worksite.',
      'Work/rest 50/10 — 50 min work / 10 min rest per hour.\nHydration: start the workday hydrated and monitor hydration status (urine color and frequency, thirst, daily body-weight changes) to dictate fluid consumption. Consider ~8 oz water every 15–20 min; electrolyte drinks required for shifts over 2 hours. Avoid alcohol and caffeine.\nRest in shade or a cooled area. Implement the buddy system and brief all workers on heat-illness symptoms. Unacclimatized workers should not perform heavy tasks at this threshold. Supervisor should check on workers every 30 minutes.',
      'Work/rest 40/20 — 40 min work / 20 min rest per hour.\nHydration: start the workday hydrated and monitor hydration status (urine color and frequency, thirst, daily body-weight changes) to dictate fluid consumption. Consider ~8 oz water every 15–20 min; electrolyte drinks required for shifts over 2 hours. Avoid alcohol and caffeine.\nRest in shade or an air-conditioned area. Implement the buddy system and rotate workers to limit individual heat exposure. Unacclimatized workers should not perform heavy work. Consider rescheduling non-essential heavy tasks to cooler hours. Supervisor on site at all times.',
      'Work/rest 30/30 — 30 min work / 30 min rest per hour.\nHydration: start the workday hydrated and monitor hydration status (urine color and frequency, thirst, daily body-weight changes) to dictate fluid consumption. Consider ~8 oz water every 15–20 min; electrolyte drinks required for shifts over 2 hours. Avoid alcohol and caffeine.\nRest in shade or an air-conditioned area. Implement the buddy system and rotate workers to limit individual heat exposure. Unacclimatized workers should not perform heavy work. Consider rescheduling non-essential heavy tasks to cooler hours. Supervisor on site at all times.',
      'Work/rest 20/40 — 20 min work / 40 min rest per hour.\nHydration: all workers must hydrate in a cool area; monitor for delayed heat-illness symptoms even after work ceases.\nRest in shade or an air-conditioned area. Implement the buddy system and rotate workers to limit individual heat exposure. Unacclimatized workers should not perform heavy work. Consider rescheduling non-essential heavy tasks to cooler hours. Supervisor on site at all times.',
    ],
  },
};
const PERSONA_ORDER = ['spectators', 'volunteers', 'workers'];
const PERSONA_REF = 'Rest & hydration guidance adapted from NIOSH and U.S. Army criteria, as recommended by the Korey Stringer Institute';

/* ──────────────────────────────────────────────────────────────────────────
   SEVERITY + COLOR helpers — pure functions on °F values, persona-aware.
   ────────────────────────────────────────────────────────────────────────── */
function thresholdsFor(metric, persona = 'spectators') {
  const p = PERSONAS[persona] || PERSONAS.spectators;
  return p.thresholds[metric] || METRICS[metric].thresholds;
}
function tierFor(metric, valF, persona = 'spectators') {
  const t = thresholdsFor(metric, persona);
  if (valF >= t[3]) return 4;
  if (valF >= t[2]) return 3;
  if (valF >= t[1]) return 2;
  if (valF >= t[0]) return 1;
  return 0;
}
function tierLabel(metric, valF, persona = 'spectators') { return (PERSONAS[persona] || PERSONAS.spectators).tierLabels[tierFor(metric, valF, persona)]; }
function tierTone(metric, valF, persona = 'spectators')  { return TIER_TONES[tierFor(metric, valF, persona)]; }
function tierColor(metric, valF, persona = 'spectators') { return TIER_COLORS[tierFor(metric, valF, persona)]; }

function lerpHex(a, b, t) {
  const pa = [parseInt(a.slice(1, 3), 16), parseInt(a.slice(3, 5), 16), parseInt(a.slice(5, 7), 16)];
  const pb = [parseInt(b.slice(1, 3), 16), parseInt(b.slice(3, 5), 16), parseInt(b.slice(5, 7), 16)];
  const m  = pa.map((v, i) => Math.round(v + (pb[i] - v) * t));
  return '#' + m.map(v => v.toString(16).padStart(2, '0')).join('');
}
function darken(hex, f = 0.28) {
  const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
  return '#' + [r, g, b].map(v => Math.round(v * f).toString(16).padStart(2, '0')).join('');
}

/* Vertical gradient stops for the chart stroke/fill, derived from thresholds.
   offset 0 = top (domMax) ... 1 = bottom (domMin). Returns [{offset,color}].
   domain defaults to the metric's full scale, but charts pass their own data
   domain so the color bands line up with the plotted y-range. */
function gradientStops(metric, domMin, domMax, persona = 'spectators') {
  const m = METRICS[metric];
  const lo = (domMin == null) ? m.scaleMin : domMin;
  const hi = (domMax == null) ? m.scaleMax : domMax;
  const off = (t) => Math.max(0, Math.min(1, (hi - t) / (hi - lo)));
  const c = TIER_COLORS;
  const stops = [{ offset: 0, color: c[4] }];
  const th = thresholdsFor(metric, persona);
  const bounds = [th[3], th[2], th[1], th[0]];
  const above = [c[4], c[3], c[2], c[1]];
  const below = [c[3], c[2], c[1], c[0]];
  bounds.forEach((b, i) => {
    const o = off(b);
    stops.push({ offset: o, color: above[i] });
    stops.push({ offset: o, color: below[i] });
  });
  stops.push({ offset: 1, color: c[0] });
  return stops;
}

/* ──────────────────────────────────────────────────────────────────────────
   UNIT CONVERSION + FORMATTING.  Internal storage is °F.
   ────────────────────────────────────────────────────────────────────────── */
function toUnit(valF, unit) { return unit === 'C' ? (valF - 32) * 5 / 9 : valF; }
function fmtTemp(valF, unit, digits = 0) {
  const v = toUnit(valF, unit);
  return v.toFixed(digits);
}
function unitSym(unit) { return unit === 'C' ? '°C' : '°F'; }

/* ──────────────────────────────────────────────────────────────────────────
   SYNTHETIC GENERATORS — ⚠️ REPLACE THESE with real feeds later.

   buildForecast(stadium, metric) → 72 points (3 days × 24h), shape:
       { idx, day (0-2), h (0-23), ambient (°F), value (°F effective) }
   buildHistory(stadium, metric, days=30) → daily peaks, oldest→newest, shape:
       { idx, dateLabel, dow, peak (°F, effective), peakAmbient (°F), peakHour }
   ────────────────────────────────────────────────────────────────────────── */
const HOURS = 24, DAYS = 3;

function seeded(seed) { // deterministic 0..1 PRNG
  let s = seed % 2147483647; if (s <= 0) s += 2147483646;
  return () => (s = (s * 16807) % 2147483647) / 2147483647;
}
function strSeed(str) { let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0; return Math.abs(h) + 1; }

function dayCurve(h, peakHour, min, peak, rnd) {
  const amp = peak - min;
  const dist = h - peakHour;
  const bell = Math.exp(-(dist * dist) / (2 * 4.7 * 4.7));        // afternoon peak
  const dawnDip = -0.12 * amp * Math.exp(-((h - 5) ** 2) / 7);    // pre-dawn minimum
  const noise = (rnd() - 0.5) * amp * 0.06;
  return min + amp * bell + dawnDip + noise;
}

/* ──────────────────────────────────────────────────────────────────────────
   REAL FORECAST DATA — loaded from data/worldcup-forecast.csv.
   ⚠️ Source: National Weather Service (NWS) — hourly point forecasts per
   venue, timestamps in UTC (converted to venue-local for display).
   To refresh: overwrite data/worldcup-forecast.csv — columns:
   Venue,TimeUTC,WBGT,HeatIndex,AmbientTemperature (one row per venue-hour).
   Venues present use the NWS forecast; absent venues fall back to modeled.
   ────────────────────────────────────────────────────────────────────────── */
const REAL_FORECAST = {}; // stadium id → { days, points: [{day,h,wbgt,heatIndex,ambient,dayLabel,dateLabel}] }
const FORECAST_SOURCE = 'National Weather Service (NWS)';

/* venue-local UTC offset, June 2026 (US/Canada on DST; Mexico has no DST) */
function utcOffsetHours(stadium) {
  if (stadium.country === 'Mexico') return -6;
  return { ET: -4, CT: -5, PT: -7 }[stadium.tz] != null ? { ET: -4, CT: -5, PT: -7 }[stadium.tz] : -5;
}

function parseForecastCSV(text) {
  const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const DOWS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  const lines = text.trim().split(/\r?\n/);
  const byVenue = {};
  for (let i = 1; i < lines.length; i++) {
    const parts = lines[i].split(',');
    if (parts.length < 5) continue;
    const tUtc = Date.parse(parts[1]);
    if (isNaN(tUtc)) continue;
    (byVenue[parts[0]] = byVenue[parts[0]] || []).push({ tUtc, wbgt: parseFloat(parts[2]), heatIndex: parseFloat(parts[3]), ambient: parseFloat(parts[4]) });
  }
  for (const venue of Object.keys(byVenue)) {
    const stadium = STADIUMS.find(s => s.venue === venue);
    if (!stadium) continue;
    const off = utcOffsetHours(stadium) * 3600000;
    const rows = byVenue[venue].sort((a, b) => a.tUtc - b.tUtc);
    const day0 = (() => { const d = new Date(rows[0].tUtc + off); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); })();
    const points = rows.map(r => {
      const d = new Date(r.tUtc + off);
      const day = Math.round((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) - day0) / 86400000);
      const dateLabel = `${DOWS[d.getUTCDay()]} ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
      return {
        day, h: d.getUTCHours(),
        wbgt: r.wbgt, heatIndex: r.heatIndex, ambient: r.ambient,
        dayLabel: day === 0 ? 'Today' : day === 1 ? 'Tomorrow' : dateLabel,
        dateLabel,
      };
    });
    REAL_FORECAST[stadium.id] = { days: new Set(points.map(p => p.day)).size, points };
  }
  return REAL_FORECAST;
}

const FORECAST_READY = (typeof fetch === 'undefined')
  ? Promise.resolve(null)
  : fetch(RES('fcCsv', 'data/worldcup-forecast.csv'))
      .then(r => { if (!r.ok) throw new Error('forecast csv: HTTP ' + r.status); return r.text(); })
      .then(parseForecastCSV)
      .catch(err => { console.warn('NWS forecast unavailable, using modeled data.', err); return null; });

/* ──────────────────────────────────────────────────────────────────────────
   LIVE CURRENT CONDITIONS — Perry Weather widget API, one widget ID per venue.
   GET https://widget.api.perryweather.com/v1/Widget/Conditions/{widgetId}
   Polled every 60s; values feed the “Now” readings. If a request fails
   (network/CORS) the UI falls back to the NWS forecast value for the current
   venue-local hour. Field names are matched tolerantly (wbgt / heatIndex /
   feelsLike / temperature) — adjust parseLiveConditions() if the payload uses
   different keys.
   ────────────────────────────────────────────────────────────────────────── */
const PW_WIDGET_BASE = 'https://widget.api.perryweather.com/v1/Widget/Conditions/';
const WIDGET_IDS = {
  atl: '964b10cc-4447-4a1f-bf7d-0556d64e01f3',
  nyc: '83b7ff06-90c5-44fd-9ef7-1551ba984344',
  van: '4adcd226-8eaa-4b9f-a230-1f772c532ca3',
  bos: '0b884c89-5ba6-4903-a087-27c9933800e4',
  mty: '112cd656-1010-4cff-be05-4b275601f879',
  mex: '9c383cd1-0c4b-4c59-8862-67af0acc63ba',
  tor: '1f79b664-e5b0-41bb-8681-6a35af74bbf9',
  mia: '1e004db4-2035-4315-8178-6cf6089b56b7',
  kan: '46719d00-cf11-4f1d-8e3b-6f4b62bb7948',
  phi: '9015966d-e613-4869-b7c8-9b786e758d89',
  gdl: '12829fba-0651-4765-99c3-a2619046924c',
  sfo: '22198747-91e1-4660-8a02-b1b0a400cc02',
  hou: 'e72a766a-00a3-431d-a968-e5d2bc2b5a8f',
  sea: '9d68015d-c4cf-457b-926c-e788a939edc3',
  lax: '5f535441-c6fe-481a-b1b0-f541e130593e',
  dal: '5f2c4167-d606-4585-a07e-f595492aacbd',
};
const LIVE_NOW = {}; // stadium id → { wbgt, heatIndex, ambient, at, ok }
const LIVE_MAX_AGE = 10 * 60000; // treat live values older than 10 min as stale

/* tolerant field extraction — walks the payload for numeric leaves */
function parseLiveConditions(json) {
  const found = {};
  (function walk(o) {
    if (!o || typeof o !== 'object') return;
    for (const k of Object.keys(o)) {
      const v = o[k];
      if (typeof v === 'number' && isFinite(v)) {
        const lk = k.toLowerCase();
        if (found.wbgt == null && (lk.includes('wbgt') || lk.includes('wetbulbglobe'))) found.wbgt = v;
        if (found.heatIndex == null && (lk.includes('heatindex') || lk.includes('feelslike'))) found.heatIndex = v;
        if (found.ambient == null && (lk === 'temp' || lk === 'temperature' || lk === 'tempf' || lk.includes('ambienttemp') || lk === 'airtemperature')) found.ambient = v;
      } else if (typeof v === 'object') walk(v);
    }
  })(json);
  return found;
}

let liveTimer = null;
function startLivePolling(onUpdate, intervalMs = 60000) {
  async function poll() {
    await Promise.all(STADIUMS.map(async (s) => {
      const wid = WIDGET_IDS[s.id];
      if (!wid) return;
      try {
        const r = await fetch(PW_WIDGET_BASE + wid, { headers: { Accept: 'application/json' } });
        if (!r.ok) throw new Error('HTTP ' + r.status);
        const vals = parseLiveConditions(await r.json());
        if (vals.wbgt == null && vals.heatIndex == null && vals.ambient == null) throw new Error('no recognizable fields');
        LIVE_NOW[s.id] = { ...vals, at: Date.now(), ok: true };
      } catch (e) {
        // keep last good values until they go stale; mark failure otherwise
        const prev = LIVE_NOW[s.id];
        if (!prev || !prev.ok || Date.now() - prev.at > LIVE_MAX_AGE) LIVE_NOW[s.id] = { at: Date.now(), ok: false };
      }
    }));
    if (onUpdate) onUpdate();
  }
  poll();
  if (liveTimer) clearInterval(liveTimer);
  liveTimer = setInterval(poll, intervalMs);
}

/* fresh live value or null */
function liveNow(stadiumId, metric) {
  const l = LIVE_NOW[stadiumId];
  if (l && l.ok && typeof l[metric] === 'number' && Date.now() - l.at <= LIVE_MAX_AGE) return l[metric];
  return null;
}

/* current hour at the venue (0–23) */
function venueLocalHour(stadium) {
  return new Date(Date.now() + utcOffsetHours(stadium) * 3600000).getUTCHours();
}

/* user-local clock label, e.g. "2:41 PM" */
function clockLabel(d = new Date()) {
  let h = d.getHours();
  const m = d.getMinutes();
  const ap = h < 12 ? 'AM' : 'PM';
  h = h % 12 || 12;
  return `${h}:${String(m).padStart(2, '0')} ${ap}`;
}

/* buildForecast — NWS data when available, modeled otherwise. */
function buildForecast(stadium, metric) {
  const rf = REAL_FORECAST[stadium.id];
  if (rf) return rf.points.map((p, i) => ({
    idx: i, day: p.day, h: p.h, ambient: p.ambient, value: p[metric],
    dayLabel: p.dayLabel, dateLabel: p.dateLabel,
  }));
  return buildSyntheticForecast(stadium, metric);
}

function buildSyntheticForecast(stadium, metric) {
  const [min, peak] = stadium.climate[metric];
  const reduction = ROOFS[stadium.roof][metric];
  const rnd = seeded(strSeed(stadium.id + metric));
  const out = [];
  for (let d = 0; d < DAYS; d++) {
    const peakHour = 14 + Math.round((rnd() - 0.4) * 3);          // ~13–16h
    const dayPeak = peak - d * (peak - min) * 0.05 * rnd();       // slight day-to-day drift
    for (let h = 0; h < HOURS; h++) {
      const ambient = dayCurve(h, peakHour, min, dayPeak, rnd);
      out.push({
        idx: d * HOURS + h, day: d, h,
        ambient: +ambient.toFixed(1),
        value: +Math.max(40, ambient - reduction).toFixed(1),
      });
    }
  }
  return out;
}

function buildHistory(stadium, metric, days = 30) {
  const [min, peak] = stadium.climate[metric];
  const reduction = ROOFS[stadium.roof][metric];
  const rnd = seeded(strSeed(stadium.id + metric + 'hist'));
  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const dows = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  // anchor: "today" = Jun 20, 2026 (Sat). Walk back `days`.
  const today = new Date(2026, 5, 20);
  const out = [];
  for (let i = days - 1; i >= 0; i--) {
    const dt = new Date(today); dt.setDate(today.getDate() - i);
    // seasonal warming trend toward present + weather noise
    const trend = (1 - i / days) * (peak - min) * 0.22;
    const swing = (rnd() - 0.5) * (peak - min) * 0.5;
    const peakAmbient = Math.max(min + 2, peak - (peak - min) * 0.32 + trend + swing);
    out.push({
      idx: days - 1 - i,
      date: dt,
      dateLabel: `${months[dt.getMonth()]} ${dt.getDate()}`,
      dow: dows[dt.getDay()],
      peakHour: 13 + Math.round(rnd() * 3),
      peakAmbient: +peakAmbient.toFixed(1),
      peak: +Math.max(40, peakAmbient - reduction).toFixed(1),
    });
  }
  return out;
}

/* per-day historical baseline (deterministic). daysBack: 0 = today (Jun 20). */
function histDayBaseline(stadium, metric, daysBack) {
  const [min, peak] = stadium.climate[metric];
  const rnd = seeded(strSeed(stadium.id + metric + 'hist' + daysBack));
  const span = peak - min;
  const cool = daysBack * span * 0.012;          // mild cooling going further back
  const swing = (rnd() - 0.5) * span * 0.45;     // day-to-day weather variation
  const dayPeak = Math.max(min + 3, peak - cool + swing);
  const dayMin = Math.max(min - 6, min - cool * 0.4 + (rnd() - 0.5) * span * 0.18);
  const peakHour = 13 + Math.round(rnd() * 3);
  return { dayMin, dayPeak, peakHour };
}

const HIST_ANCHOR = new Date(2026, 5, 20, 12, 0); // "now" = Sat Jun 20, 12:00

/* effective metric value at an absolute timestamp (deterministic). */
function histValueAt(stadium, metric, dt) {
  const reduction = ROOFS[stadium.roof][metric];
  const midnightToday = new Date(2026, 5, 20); midnightToday.setHours(0, 0, 0, 0);
  const dMid = new Date(dt); dMid.setHours(0, 0, 0, 0);
  const daysBack = Math.max(0, Math.round((midnightToday - dMid) / 86400000));
  const base = histDayBaseline(stadium, metric, daysBack);
  const amp = base.dayPeak - base.dayMin;
  const hf = dt.getHours() + dt.getMinutes() / 60;
  const bell = Math.exp(-((hf - base.peakHour) ** 2) / (2 * 4.7 * 4.7));
  const dawnDip = -0.12 * amp * Math.exp(-((hf - 5) ** 2) / 7);
  const nr = seeded(strSeed(stadium.id + metric + daysBack + '_' + dt.getHours() + '_' + dt.getMinutes()))();
  const noise = (nr - 0.5) * amp * 0.05;
  const ambient = base.dayMin + amp * bell + dawnDip + noise;
  return +Math.max(40, ambient - reduction).toFixed(1);
}

/* ──────────────────────────────────────────────────────────────────────────
   REAL HISTORICAL DATA — loaded from data/worldcup-heat.csv (Perry Weather
   station observations; 15-min avg-max readings per venue, venue-local time).
   To refresh daily: overwrite data/worldcup-heat.csv with the new export —
   same columns: ObservedAt,Venue,Stations,AvgMaxWBGT,AvgMaxAmbientTemperature,AvgMaxHeatIndex.
   Venues present in the CSV automatically use real data (and their PW station
   count); venues absent fall back to the modeled synthetic generators.
   ────────────────────────────────────────────────────────────────────────── */
const REAL_HISTORY = {}; // stadium id → { stations, points: [{t, wbgt, ambient, heatIndex}] }

function parseHistoryCSV(text) {
  const lines = text.trim().split(/\r?\n/);
  const byVenue = {};
  for (let i = 1; i < lines.length; i++) {
    const parts = lines[i].split(',');
    if (parts.length < 6) continue;
    const m = /(\d+)\/(\d+)\/(\d+)\s+(\d+):(\d+)/.exec(parts[0]);
    if (!m) continue;
    const t = new Date(2000 + +m[3], +m[1] - 1, +m[2], +m[4], +m[5]).getTime();
    const wbgt = parseFloat(parts[3]), ambient = parseFloat(parts[4]), heatIndex = parseFloat(parts[5]);
    if (isNaN(wbgt)) continue;
    (byVenue[parts[1]] = byVenue[parts[1]] || []).push({ t, st: +parts[2], wbgt, ambient, heatIndex });
  }
  for (const venue of Object.keys(byVenue)) {
    const stadium = STADIUMS.find(s => s.venue === venue);
    if (!stadium) continue;
    const pts = byVenue[venue].sort((a, b) => a.t - b.t);
    const sts = pts.map(p => p.st).sort((a, b) => a - b);
    REAL_HISTORY[stadium.id] = { stations: sts[Math.floor(sts.length / 2)], points: pts };
  }
  // station counts come from the data itself; venues not in the feed have none
  STADIUMS.forEach(s => { PW_STATIONS[s.id] = REAL_HISTORY[s.id] ? REAL_HISTORY[s.id].stations : null; });
  return REAL_HISTORY;
}

const HISTORY_READY = (typeof fetch === 'undefined')
  ? Promise.resolve(null)
  : fetch(RES('histCsv', 'data/worldcup-heat.csv'))
      .then(r => { if (!r.ok) throw new Error('history csv: HTTP ' + r.status); return r.text(); })
      .then(parseHistoryCSV)
      .catch(err => { console.warn('Real history unavailable, using modeled data.', err); return null; });

/* series builder over the real observations (max within each bucket). */
function buildRealHistorySeries(real, metric, rangeDays) {
  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const dows = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  const fmtDate = (dt) => `${months[dt.getMonth()]} ${dt.getDate()}`;
  const fmtClock = (dt) => {
    const h = dt.getHours(); const ap = h < 12 ? 'a' : 'p';
    const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
    const mm = dt.getMinutes();
    return mm === 0 ? `${hh}${ap}` : `${hh}:${String(mm).padStart(2, '0')}${ap}`;
  };
  const pts = real.points;
  // exclude the current (upload) day — history shows complete days only.
  // cutoff = midnight of the most recent calendar date in the feed.
  const lastDay = new Date(pts[pts.length - 1].t); lastDay.setHours(0, 0, 0, 0);
  const cutoff = lastDay.getTime();
  const win = pts.filter(p => p.t < cutoff && p.t >= cutoff - rangeDays * 86400000);

  if (rangeDays > 7) { // daily max
    const byDay = new Map();
    for (const p of win) {
      const d = new Date(p.t); d.setHours(0, 0, 0, 0);
      const k = d.getTime();
      if (!byDay.has(k) || p[metric] > byDay.get(k)[metric]) byDay.set(k, p);
    }
    const points = [...byDay.keys()].sort((a, b) => a - b).map((k, i) => {
      const dt = new Date(k);
      return { idx: i, value: +byDay.get(k)[metric].toFixed(1), axisLabel: fmtDate(dt), tipTitle: `${dows[dt.getDay()]} ${fmtDate(dt)}` };
    });
    return { mode: 'daily', resLabel: 'Daily max', points };
  }

  const stepMin = rangeDays <= 1 ? 15 : rangeDays <= 3 ? 60 : 180;
  const resLabel = stepMin === 15 ? '15-minute readings' : stepMin === 60 ? 'Hourly max' : '3-hour max';
  const byBucket = new Map();
  for (const p of win) {
    const k = Math.floor(p.t / (stepMin * 60000));
    if (!byBucket.has(k) || p[metric] > byBucket.get(k)) byBucket.set(k, p[metric]);
  }
  const points = [...byBucket.keys()].sort((a, b) => a - b).map((k, i) => {
    const dt = new Date(k * stepMin * 60000);
    return {
      idx: i, value: +byBucket.get(k).toFixed(1),
      axisLabel: rangeDays <= 1 ? fmtClock(dt) : fmtDate(dt),
      tipTitle: rangeDays <= 1 ? fmtClock(dt) : `${fmtDate(dt)} · ${fmtClock(dt)}`,
    };
  });
  return { mode: 'sub', resLabel, points };
}

/* HISTORY SERIES — adaptive resolution by range:
     1 day → 15-min · ≤3 days → hourly · ≤7 days → 3-hour · >7 days → daily max.
   returns { mode, resLabel, points: [{ idx, value (°F eff), axisLabel, tipTitle }] }. */
function buildHistorySeries(stadium, metric, rangeDays) {
  if (REAL_HISTORY[stadium.id]) return buildRealHistorySeries(REAL_HISTORY[stadium.id], metric, rangeDays);
  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const dows = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  const reduction = ROOFS[stadium.roof][metric];
  const fmtDate = (dt) => `${months[dt.getMonth()]} ${dt.getDate()}`;
  const fmtClock = (dt) => {
    const h = dt.getHours(); const ap = h < 12 ? 'a' : 'p';
    const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
    const mm = dt.getMinutes();
    return mm === 0 ? `${hh}${ap}` : `${hh}:${String(mm).padStart(2, '0')}${ap}`;
  };

  // DAILY MAX mode (long ranges)
  if (rangeDays > 7) {
    const points = [];
    for (let i = rangeDays - 1; i >= 0; i--) {
      const dt = new Date(2026, 5, 20); dt.setDate(dt.getDate() - i);
      const base = histDayBaseline(stadium, metric, i);
      const value = +Math.max(40, base.dayPeak - reduction).toFixed(1);
      points.push({ idx: rangeDays - 1 - i, value, axisLabel: fmtDate(dt), tipTitle: `${dows[dt.getDay()]} ${fmtDate(dt)}` });
    }
    return { mode: 'daily', resLabel: 'Daily peak', points };
  }

  // SUB-DAILY modes
  const stepMin = rangeDays <= 1 ? 15 : rangeDays <= 3 ? 60 : 180;
  const resLabel = stepMin === 15 ? '15-minute readings' : stepMin === 60 ? 'Hourly readings' : '3-hour readings';
  const points = [];
  const totalMin = rangeDays * 24 * 60;
  let idx = 0;
  for (let mAgo = totalMin; mAgo >= 0; mAgo -= stepMin) {
    const dt = new Date(HIST_ANCHOR.getTime() - mAgo * 60000);
    const value = histValueAt(stadium, metric, dt);
    const axisLabel = rangeDays <= 1 ? fmtClock(dt) : fmtDate(dt);
    const tipTitle = rangeDays <= 1 ? fmtClock(dt) : `${fmtDate(dt)} · ${fmtClock(dt)}`;
    points.push({ idx: idx++, value, axisLabel, tipTitle });
  }
  return { mode: 'sub', resLabel, points };
}
function matchRiskValue(stadium, metric) {
  const nm = nextMatchFor(stadium);
  const rf = REAL_FORECAST[stadium.id];
  if (rf) {
    // use the NWS forecast when the fixture falls inside its horizon
    const pt = rf.points.find(p => p.day === nm.dayOffset && p.h === nm.kickoff);
    if (pt) return pt[metric];
    return kickoffValue(stadium, metric, nm.kickoff);
  }
  const fc = buildForecast(stadium, metric);
  const pt = fc.find(p => p.day === nm.dayOffset && p.h === nm.kickoff) || fc[0];
  return pt.value;
}

/* representative effective metric value at a given local kickoff hour (°F). */
function kickoffValue(stadium, metric, hour) {
  const [min, peak] = stadium.climate[metric];
  const reduction = ROOFS[stadium.roof][metric];
  const amp = peak - min;
  const bell = Math.exp(-((hour - 14) ** 2) / (2 * 4.7 * 4.7));
  const dawnDip = -0.12 * amp * Math.exp(-((hour - 5) ** 2) / 7);
  const ambient = min + amp * bell + dawnDip;
  return +Math.max(40, ambient - reduction).toFixed(0);
}

/* ──────────────────────────────────────────────────────────────────────────
   MATCH SCHEDULE — ⚠️ SYNTHETIC. Replace buildSchedule() with the real
   per-venue fixture list. Each match: { id, dateLabel, dow, kickoff (0–23 local),
   stage, teams, isNext }.  Dates span the 2026 tournament window (Jun 11–Jul 19).
   ────────────────────────────────────────────────────────────────────────── */
const SCHED_TEAMS = ['Brazil','France','England','Spain','Germany','Argentina','Portugal','Netherlands',
  'Croatia','Belgium','Uruguay','Mexico','USA','Japan','Morocco','Senegal','Colombia','Denmark',
  'Switzerland','Korea Rep.','Sweden','Austria','Cape Verde','Canada','Ecuador','Nigeria','Australia','Poland'];
const TEAM_FLAGS = {
  Brazil: '🇧🇷', France: '🇫🇷', England: '🏴󠁧󠁢󠁥󠁮󠁧󠁿', Spain: '🇪🇸', Germany: '🇩🇪',
  Argentina: '🇦🇷', Portugal: '🇵🇹', Netherlands: '🇳🇱', Croatia: '🇭🇷', Belgium: '🇧🇪',
  Uruguay: '🇺🇾', Mexico: '🇲🇽', USA: '🇺🇸', Japan: '🇯🇵', Morocco: '🇲🇦',
  Senegal: '🇸🇳', Colombia: '🇨🇴', Denmark: '🇩🇰', Switzerland: '🇨🇭', 'Korea Rep.': '🇰🇷',
  Sweden: '🇸🇪', Austria: '🇦🇹', 'Cape Verde': '🇨🇻', Canada: '🇨🇦', Ecuador: '🇪🇨',
  Nigeria: '🇳🇬', Australia: '🇦🇺', Poland: '🇵🇱',
};
/* '🇸🇳 Senegal' — prefix each side of 'A vs B' with its flag emoji */
function flagTeam(t) { return (TEAM_FLAGS[t] ? TEAM_FLAGS[t] + ' ' : '') + t; }
const SCHED_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const SCHED_DOWS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const KICKOFF_SLOTS = [12, 15, 18, 21];

function buildSchedule(stadium) {
  const rnd = seeded(strSeed(stadium.id + 'sched'));
  const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
  const usedTeams = new Set();
  const distinctTeam = () => { let t, g = 0; do { t = pick(SCHED_TEAMS); g++; } while (usedTeams.has(t) && g < 12); usedTeams.add(t); return t; };
  const groupLetter = pick(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']);
  const parseDate = (label) => { // "Sat Jun 20" / "Jun 20" → Date(2026,…)
    const parts = label.replace(/^[A-Za-z]{3}\s+/, '').split(/\s+/);
    const mo = SCHED_MONTHS.indexOf(parts[0]);
    return new Date(2026, mo < 0 ? 5 : mo, +parts[1] || 20);
  };

  // group-stage match days (Jun) + one knockout fixture, deterministic per venue
  const groupDates = [11, 12, 14, 16, 18, 20, 22, 24, 26];
  const start = Math.floor(rnd() * 3);
  const days = [groupDates[start], groupDates[start + 2], groupDates[start + 4], groupDates[start + 5]].filter(Boolean);
  const knockout = [
    { d: 29, m: 5, stage: 'Round of 32' }, { d: 30, m: 5, stage: 'Round of 32' },
    { d: 3, m: 6, stage: 'Round of 16' }, { d: 6, m: 6, stage: 'Round of 16' },
  ][Math.floor(rnd() * 4)];

  const rows = days.map((d, i) => {
    const dt = new Date(2026, 5, d);
    return {
      id: `${stadium.id}-g${i}`,
      dateObj: dt,
      dateLabel: `${SCHED_MONTHS[dt.getMonth()]} ${dt.getDate()}`,
      dow: SCHED_DOWS[dt.getDay()],
      kickoff: pick(KICKOFF_SLOTS),
      stage: `Group ${groupLetter} · MD${i + 1}`,
      teams: `${distinctTeam()} vs ${distinctTeam()}`,
    };
  });
  const kdt = new Date(2026, knockout.m, knockout.d);
  rows.push({
    id: `${stadium.id}-ko`,
    dateObj: kdt,
    dateLabel: `${SCHED_MONTHS[kdt.getMonth()]} ${kdt.getDate()}`,
    dow: SCHED_DOWS[kdt.getDay()],
    kickoff: pick(KICKOFF_SLOTS),
    stage: knockout.stage,
    teams: 'Winner vs Winner',
  });

  rows.sort((a, b) => a.dateObj - b.dateObj);

  /* completion + final score: a game is complete ~2¼ h after kickoff (venue-local). */
  const off = utcOffsetHours(stadium);
  rows.forEach((r) => {
    const startUtc = Date.UTC(2026, r.dateObj.getMonth(), r.dateObj.getDate(), r.kickoff - off);
    r.completed = Date.now() > startUtc + 2.25 * 3600000;
    if (r.completed && r.teams.indexOf('Winner') === -1) {
      const rr = seeded(strSeed(stadium.id + r.id + 'score'));
      r.score = `${Math.floor(rr() * 4)}–${Math.floor(rr() * 3)}`;
    }
    const [ha, hb] = r.teams.split(' vs ');
    r.teamA = ha; r.teamB = hb;
  });
  return rows;
}

/* peak metric value (°F) for a given calendar day at a venue — PW history first,
   NWS forecast for today, modeled fallback otherwise. */
function dayPeakValue(stadium, metric, dateObj) {
  let peak = null;
  const real = REAL_HISTORY[stadium.id];
  if (real) {
    const y = dateObj.getFullYear(), mo = dateObj.getMonth(), da = dateObj.getDate();
    for (const p of real.points) {
      const d = new Date(p.t);
      if (d.getFullYear() === y && d.getMonth() === mo && d.getDate() === da) {
        if (peak == null || p[metric] > peak) peak = p[metric];
      }
    }
  }
  const rf = REAL_FORECAST[stadium.id];
  if (rf) {
    const dayOffset = Math.round((new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()) - SCHED_TODAY) / 86400000);
    const pts = rf.points.filter(p => p.day === dayOffset);
    // a day may be split across feeds (history ends early morning) — take the max
    if (pts.length) peak = Math.max(peak == null ? -Infinity : peak, ...pts.map(p => p[metric]));
  }
  if (peak != null && peak !== -Infinity) return peak;
  return kickoffValue(stadium, metric, 15);
}

/* the soonest game on/after today at this venue — single source for the card. */
const SCHED_TODAY = new Date(2026, 5, 11); // "today" — keep in sync with the data feeds
function nextMatchFor(stadium) {
  const rows = buildSchedule(stadium);
  const row = rows.find(r => !r.completed) || rows[rows.length - 1];
  const dayOffset = Math.round((row.dateObj - SCHED_TODAY) / 86400000);
  return {
    stage: row.stage, teams: row.teams, kickoff: row.kickoff,
    dow: row.dow, dateLabel: row.dateLabel, dayOffset, teamA: row.teamA, teamB: row.teamB,
    whenLabel: dayOffset === 0 ? 'Today' : dayOffset === 1 ? 'Tomorrow' : `${row.dow} ${row.dateLabel}`,
  };
}

function hourLabel(h) {
  const ampm = h < 12 ? 'AM' : 'PM';
  const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
  return `${hh}:00 ${ampm}`;
}
function shortHour(h) {
  const ampm = h < 12 ? 'a' : 'p';
  const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
  return `${hh}${ampm}`;
}
const DAY_NAMES = ['Today', 'Tomorrow', 'Mon Jun 22', 'Tue Jun 23', 'Wed Jun 24'];
const DAY_DATES = ['Sat Jun 20', 'Sun Jun 21', 'Mon Jun 22', 'Tue Jun 23', 'Wed Jun 24'];

/* expose to other babel scripts */
Object.assign(window, {
  METRICS, ROOFS, STADIUMS, COUNTRY_ORDER, TIER_COLORS, TIER_TONES, DAY_NAMES, DAY_DATES, STADIUM_PHOTOS, PW_STATIONS,
  PERSONAS, PERSONA_ORDER, PERSONA_REF, thresholdsFor,
  tierFor, tierLabel, tierTone, tierColor, gradientStops, lerpHex, darken,
  toUnit, fmtTemp, unitSym, buildForecast, buildHistory, buildHistorySeries, matchRiskValue, kickoffValue, buildSchedule, nextMatchFor, dayPeakValue, TEAM_FLAGS, flagTeam,
  REAL_HISTORY, HISTORY_READY, REAL_FORECAST, FORECAST_READY, FORECAST_SOURCE,
  WIDGET_IDS, LIVE_NOW, startLivePolling, liveNow, venueLocalHour, clockLabel, utcOffsetHours,
  hourLabel, shortHour, strSeed, seeded,
});
window.DATA_READY = Promise.all([HISTORY_READY, FORECAST_READY]);
