// ════════════════════════════════════════════════════════════════════════ // compute.js — Build the per-hour display rows from the raw API data. // // Pure-ish function: feed in (forecast, airQuality, location, vehicleType, // vehicleVent, buildingType) and get back { hourlyRows, days, utcOffsetMs }. // // Open-Meteo with timezone=auto returns local wall-clock strings like // "2026-05-13T14:00" — no Z suffix. Two forms are used in each row: // • String slices (iso.slice(...)) for display & day grouping // • A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*). // ════════════════════════════════════════════════════════════════════════ import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass, calcManagedIndoorTempPass, } from './physics.js'; import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js'; export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType }) { const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; // Build a fast lookup map from the air quality hourly data: ISO string → index. // Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like // "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14". const aqTimeMap = {}; if (airQuality?.hourly?.time) { airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; }); } const getAq = (field, iso) => { if (!airQuality?.hourly?.[field]) return null; const i = aqTimeMap[iso.slice(0, 13)]; if (i === undefined) return null; return airQuality.hourly[field][i] ?? null; }; const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { const h = forecast.hourly; const Ta = h.temperature_2m[i]; const RH = h.relative_humidity_2m[i]; const dew = h.dew_point_2m ? h.dew_point_2m[i] : null; const va = h.wind_speed_10m[i]; const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null; const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null; const dir = h.direct_radiation[i] || 0; const dif = h.diffuse_radiation[i] || 0; const glob = h.shortwave_radiation[i] || 0; const cc = h.cloud_cover[i]; const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null; const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null; const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null; const uv = h.uv_index ? (h.uv_index[i] || 0) : 0; const precip = h.precipitation[i] || 0; const snow = h.snowfall[i] || 0; const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; // Scale radiation by cloud cover before passing to concrete calc. // shortwave_radiation and cloud_cover are separate forecast fields that // don't always agree — this prevents hot concrete values on cloudy days. // 100% cloud -> 85% reduction, 0% cloud -> full radiation. const effectiveRad = glob * (1 - (cc / 100) * 0.85); const concreteT = calcConcreteTemp(Ta, effectiveRad, va); // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). // For display we slice the string directly — no Date object needed. // For solarElevationDeg (which uses .getUTC* internally) we need the // true UTC instant: treat the local time as UTC then subtract the offset. // e.g. Brisbane UTC+10: local 14:00 → parse as UTC 14:00 → subtract 10h → UTC 04:00 ✓ const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs); const elev = solarElevationDeg(location.lat, location.lon, dt); const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); const eh = vaporPressureHpa(Ta, RH); const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); const utci = utciApprox(Ta, Tmrt, va, eh); const utciAdj = utci + precipPenalty(precip, snow, va); // Derived const compass = windCompass8(wd); const { uvA, uvB } = uvSplit(uv, elev); const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); // Visibility from the main forecast API (metres → km). const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })(); const aqi = getAq('european_aqi', iso); const grassPollen = getAq('grass_pollen', iso); const birchPollen = getAq('birch_pollen', iso); const alderPollen = getAq('alder_pollen', iso); const mugwortPollen= getAq('mugwort_pollen', iso); const olivePollen = getAq('olive_pollen', iso); const ragweedPollen= getAq('ragweed_pollen', iso); // ── FUTURE FEATURE: Activity "What If" Modifier ─────────────────────────── // Add two extra columns driven by a user-selected activity level. These are // intentionally kept SEPARATE from the core columns above so that baseline // profile data stays consistent and comparable across profiles. // // The user picks an activity from a simple UI picker (no live data needed — // this is a forecast/planning tool, not a tracker): // Resting → Walking → Cycling → Running → Sport/Intense // // Two output columns only (keep it clean): // // adjustedSafeTime — baseline UV safe exposure time × an activity multiplier. // Higher activity = shorter safe time, because: // • metabolic heat raises core body temp // • sweating washes away sunscreen faster // • more skin blood flow = higher UV sensitivity // Suggested multipliers (tune with real data): // Resting: 1.0 (no change) // Walking: 0.85 // Cycling: 0.75 // Running: 0.60 // Sport: 0.50 // // heatStressLevel — a simple label: 'Low' | 'Moderate' | 'High' | 'Very High' // Derived from UTCI + activity heat load. A runner at // UTCI 28°C should read 'High' even if a resting person // would read 'Moderate' at the same UTCI. // Colour code in the UI: 🟢 🟡 🟠 🔴 // // Implementation sketch: // 1. Accept `activityLevel` as a new param to buildHourlyRows() alongside // vehicleType, buildingType etc. // 2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level). // 3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier // 4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels. // 5. Add both fields to the returned row object below. // 6. In components.js, render these as optional columns that only appear when // an activity other than 'Resting' is selected — keeps the default table clean. // ───────────────────────────────────────────────────────────────────────────── return { iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, cc, ccLow, ccMid, ccHigh, cloudCat, uv, uvA, uvB, precip, snow, soilT0, soilT6, soilM, concreteT, vehicleT, elev, Tmrt, utci, utciAdj, eh, compass, visKm, aqi, grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen, }; }) : []; // Two-pass indoor temperature: needs the full hourly arrays so thermal // lag can look back at previous hours. Run after hourlyRows is built, // then stamp each row with its indoorT value. if (hourlyRows.length > 0) { const TaArr = hourlyRows.map(r => r.Ta); const globArr = hourlyRows.map(r => r.glob); const elevArr = hourlyRows.map(r => r.elev); const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType); const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType); hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; }); } // Group those hourly rows into days for the day tabs. const days = []; hourlyRows.forEach(row => { const key = row.iso.slice(0, 10); let day = days.find(d => d.key === key); if (!day) { day = { key, rows: [] }; days.push(day); } day.rows.push(row); }); // ── FUTURE FEATURE v2: Today Summary + Alert System ────────────────────────── // After grouping rows into days, generate a per-day summary object that powers // a stylish "Today at a Glance" panel shown above or below the main dial. // // The summary is NOT a live alert/push system — it's a forecast digest that // refreshes with the forecast data. Think of it as a smart briefing card. // // WHAT TO COMPUTE (per day, from that day's rows): // • Peak UTCI+P and time it occurs → heat stress headline // • Min UTCI+P and time → cold stress headline // • Peak UV index and time → UV warning // • Max precipitation rate and time → rain/ice warning // • Max vehicle cabin temp → "dangerous to leave pets/children in car" // • Max pollen level + type → pollen advisory // • Road condition risk (low air temp + precip → ice risk) // // ALERT CATEGORIES (each generates a styled warning card if threshold exceeded): // 🌡️ Heat warning UTCI+P > 32°C // 🥶 Cold warning UTCI+P < 0°C // ☀️ UV warning UV index > 6 // 🌧️ Heavy rain precip > 4mm/h // 🧊 Ice/road risk Ta < 3°C + any precip (or recent precip overnight) // 🚗 Vehicle danger vehicleT > 35°C ("don't leave pets or children in car") // 🌿 High pollen any pollen type > 50 grains/m³ // // DESIGN NOTES: // • Cards should be concise — one line of bold text + a short explanation // • Colour-coded to match the existing UTCI stress band palette // • Collapsible — show top 2-3 alerts by default, expand for full list // • For today only (days[0]); optionally extend to day tabs in a later pass // • The "X°C above seasonal norm" historical context line (see app.js comment) // could live here too, as a subtle subheading under the dial temperature // // Suggested return shape — add to the return value below: // daySummaries: days.map(day => buildDaySummary(day.rows)) // // where buildDaySummary() is a new helper in this file (or a separate // summary.js module if it grows large). // ───────────────────────────────────────────────────────────────────────────── return { hourlyRows, days, utcOffsetMs }; }