// ------------------------------------------------------------------------ // 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, calcConcreteTempPass, calcVehicleInteriorTemp, calcIndoorTempPass, calcManagedIndoorTempPass, } from './physics.js'; import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js'; import { UTCI_ENVIRONMENTS } from './config.js'; export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv }) { const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open; 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 precipProb = h.precipitation_probability ? (h.precipitation_probability[i] ?? 0) : 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; // 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 - // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). // For solarElevationDeg (which uses .getUTC* internally) we need the // true UTC instant: treat the local time as UTC then subtract the offset. const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs); const elev = solarElevationDeg(location.lat, location.lon, dt); // Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete. // direct_radiation is zero on fully overcast days - far more accurate than // shortwave_radiation which can be unreliably high even at 100% cloud cover. // Diffuse (scattered light through cloud) contributes ~20% as much heat to // a surface as direct beam, so we weight it accordingly. const effectiveRad = dir + dif * 0.2; // concreteT is now stamped in the two-pass section below (thermal lag). const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); const eh = vaporPressureHpa(Ta, RH); // Apply environment modifier - adjust solar inputs and air temp for shaded environments. const TaEnv = Ta + env.taOffset; const dirEnv = dir * env.dirFactor; const difEnv = dif * env.difFactor; const globEnv = glob * env.globFactor; const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev); const utci = utciApprox(TaEnv, 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, precipProb, snow, soilT0, soilT6, soilM, vehicleT, effectiveRad, elev, Tmrt, utci, utciAdj, eh, compass, visKm, aqi, grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen, }; }) : []; // Two-pass calculations: concrete thermal lag + indoor temperature. // Both need the full hourly arrays so they can look back at previous // hours. Run after hourlyRows is built, then stamp each row. 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 radArr = hourlyRows.map(r => r.effectiveRad); const vaArr = hourlyRows.map(r => r.va); const uvArr = hourlyRows.map(r => r.uv); const cloudCatArr = hourlyRows.map(r => r.cloudCat); const soilMArr = hourlyRows.map(r => r.soilM); const precipArr = hourlyRows.map(r => r.precip); const snowArr = hourlyRows.map(r => r.snow); // Concrete surface temperature with thermal lag (1.5 h time constant). // A slab baking in the sun retains heat when cloud rolls in, and takes // a couple of hours of sunshine to fully heat up from a cold start. const concreteTemps = calcConcreteTempPass( TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr ); hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; }); 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 }; }