Add new quick summary panels and update layout to make space for them
This commit is contained in:
fraxle
2026-06-02 00:17:18 +01:00
parent 6734422673
commit 099f2924d3
6 changed files with 648 additions and 63 deletions
+316 -1
View File
@@ -15,7 +15,7 @@ import {
calcConcreteTempPass, calcVehicleInteriorTemp,
calcIndoorTempPass, calcManagedIndoorTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel } from './utils.js';
import { UTCI_ENVIRONMENTS } from './config.js';
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv }) {
@@ -230,3 +230,318 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
return { hourlyRows, days, utcOffsetMs };
}
// ------------------------------------------------------------------------
// computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its
// contributing factors for the "Why it feels like this" panel.
//
// Returns contributions in degrees C relative to plain air temperature.
// Positive = warmer than air temp, negative = cooler.
//
// Attribution method - sequential isolation using utciApprox:
// sunAndSky - Tmrt vs Ta. Mean radiant temp captures net heat from
// direct sun, sky scatter, and ground reflection combined.
// wind - UTCI with wind vs without (Tmrt = Ta, neutral RH 50%).
// Almost always negative - wind cools.
// humidity - UTCI at actual vapour pressure vs neutral RH 50%.
// Positive when muggy, near-zero when dry.
// environment - taOffset from the active UTCI environment modifier.
// e.g. urban +2.5, forest -2.0, desert +3.5.
// precipitation - precipPenalty() - always 0 or negative.
// ------------------------------------------------------------------------
export function computeWhyFeelsLike(row, env) {
if (!row) return null;
const { Ta, Tmrt, va, eh, precip, snow } = row;
const r1 = (v) => Math.round(v * 10) / 10;
// Radiation - mean radiant temp above or below air temp.
const sunAndSky = r1(Tmrt - Ta);
// Wind - UTCI with actual wind vs calm, holding Tmrt = Ta and RH neutral.
const ehNeutral = vaporPressureHpa(Ta, 50);
const wind = r1(utciApprox(Ta, Ta, va, ehNeutral) - utciApprox(Ta, Ta, 0, ehNeutral));
// Humidity - actual vapour pressure vs neutral RH 50%, no wind or sun.
const humidity = r1(utciApprox(Ta, Ta, 0, eh) - utciApprox(Ta, Ta, 0, ehNeutral));
// Environment modifier - explicit temperature offset from the env config.
const environment = env ? r1(env.taOffset) : 0;
// Precipitation soak penalty - negative or zero.
const precipitation = precipPenalty(precip, snow, va);
return { sunAndSky, wind, humidity, environment, precipitation };
}
// ------------------------------------------------------------------------
// computeGlanceSummary(todayRows, profile, variant, skinType) - Build the
// environment-aware "Today at a Glance" items for the current day.
//
// Returns an array of { icon, label, value, alert } objects (4-5 items).
// Content varies by profile and sub-variant so it stays relevant to
// whatever the user is actually doing.
//
// Parameters:
// todayRows - hourlyRows for the selected day
// profile - active profile key e.g. "farming", "vehicle", "home"
// variant - active sub-variant key e.g. "running", "beach" (or null)
// skinType - Fitzpatrick skin type key for UV burn time
// ------------------------------------------------------------------------
export function computeGlanceSummary(todayRows, profile, variant, skinType) {
if (!todayRows || todayRows.length === 0) return [];
const hhmm = (iso) => iso ? iso.slice(11, 16) : null;
const dayRows = todayRows.filter(r => r.elev > 0);
const peakRow = (field) => todayRows.reduce((best, r) =>
(r[field] != null && (best == null || r[field] > best[field])) ? r : best, null);
const maxRainProb = Math.max(0, ...todayRows.map(r => r.precipProb ?? 0));
// Longest run of rows that satisfy a predicate.
const longestWindow = (rows, cond) => {
let best = null, cur = null;
for (const r of rows) {
if (cond(r)) {
cur = cur ? { start: cur.start, end: r, len: cur.len + 1 } : { start: r, end: r, len: 1 };
if (!best || cur.len > best.len) best = { ...cur };
} else {
cur = null;
}
}
return best;
};
const pollenLabel = (v) => {
if (v == null || v < 0) return null;
if (v < 10) return 'Low';
if (v < 50) return 'Moderate';
if (v < 200) return 'High';
return 'Very High';
};
const aqiLabel = (v) => {
if (v == null) return null;
if (v < 20) return 'Good';
if (v < 40) return 'Fair';
if (v < 60) return 'Moderate';
return 'Poor';
};
const moistureLabel = (v) => {
if (v == null) return null;
if (v < 0.15) return 'Dry';
if (v < 0.30) return 'Slightly dry';
if (v < 0.45) return 'Moist';
return 'Saturated';
};
const rainItem = {
icon: '🌧',
label: 'Rain risk',
value: `${Math.round(maxRainProb)}%`,
alert: maxRainProb >= 60,
};
// ── Farming ────────────────────────────────────────────────────────────
if (profile === 'farming') {
const fieldWindow = longestWindow(dayRows, r =>
r.utciAdj >= 8 && r.utciAdj <= 32 && r.precipProb < 30 && r.va < 12
);
const soilWarmRow = todayRows.find(r => r.soilT0 != null && r.soilT0 >= 10);
const soilM = todayRows.find(r => r.soilM != null)?.soilM ?? null;
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [
{
icon: '⏱',
label: 'Best field work',
value: fieldWindow
? `${hhmm(fieldWindow.start.iso)} ${hhmm(fieldWindow.end.iso)}`
: 'No suitable window',
alert: !fieldWindow,
},
{
icon: '🌱',
label: 'Soil warms to 10°C by',
value: soilWarmRow ? hhmm(soilWarmRow.iso) : 'Not today',
alert: !soilWarmRow,
},
rainItem,
{
icon: '💧',
label: 'Soil moisture',
value: moistureLabel(soilM) ?? '-',
alert: soilM != null && (soilM < 0.10 || soilM > 0.50),
},
...(maxPollen >= 10 ? [{
icon: '🌿',
label: 'Grass pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Vehicle ────────────────────────────────────────────────────────────
if (profile === 'vehicle') {
const peakCabin = peakRow('vehicleT');
const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 35);
return [
{
icon: '🌡',
label: 'Peak cabin temp',
value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-',
alert: !!(peakCabin && peakCabin.vehicleT >= 35),
},
{
icon: '🧒',
label: 'Children/pets in car',
value: dangerFrom ? `Unsafe from ${hhmm(dangerFrom.iso)}` : 'Safe all day',
alert: !!dangerFrom,
},
rainItem,
{
icon: '🌤',
label: 'Best travel comfort',
value: (() => {
const best = todayRows.reduce((b, r) =>
(b == null || r.utciAdj < b.utciAdj) ? r : b, null);
return best ? `${hhmm(best.iso)} (${Math.round(best.utciAdj)}° felt)` : '-';
})(),
alert: false,
},
];
}
// ── Home ───────────────────────────────────────────────────────────────
if (profile === 'home') {
const peakIndoor = peakRow('indoorT');
const peakManaged = peakRow('managedT');
const ventWindow = longestWindow(todayRows, r =>
r.Ta != null && r.indoorT != null && r.Ta < r.indoorT && r.precipProb < 20
);
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [
{
icon: '🌡',
label: 'Peak indoor (unmanaged)',
value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
alert: !!(peakIndoor && peakIndoor.indoorT >= 28),
},
{
icon: '🌡',
label: 'Peak indoor (managed)',
value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-',
alert: !!(peakManaged && peakManaged.managedT >= 28),
},
{
icon: '🪟',
label: 'Open windows',
value: ventWindow
? `${hhmm(ventWindow.start.iso)} ${hhmm(ventWindow.end.iso)}`
: 'Keep closed',
alert: false,
},
{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
},
...(maxPollen >= 10 ? [{
icon: '🌼',
label: 'Pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Activities - running / cycling ─────────────────────────────────────
if (variant === 'running' || variant === 'cycling') {
const coolWindow = longestWindow(dayRows, r =>
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30
);
const peakUvRow = peakRow('uv');
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
const burnMins = peakUvRow && peakUvRow.uv > 0
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
: null;
return [
{
icon: '⏱',
label: `Best ${variant} window`,
value: coolWindow
? `${hhmm(coolWindow.start.iso)} ${hhmm(coolWindow.end.iso)}`
: 'No cool window today',
alert: !coolWindow,
},
rainItem,
...(burnMins ? [{
icon: '☀',
label: 'UV burn time (peak)',
value: burnMins,
alert: !!(peakUvRow && peakUvRow.uv >= 6),
}] : []),
{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
},
...(maxPollen >= 10 ? [{
icon: '🌿',
label: 'Pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Outdoors - beach, park, events etc. (and fallback) ─────────────────
const comfortWindow = longestWindow(dayRows, r =>
r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30
);
const peakFelt = peakRow('utciAdj');
const peakUvRow = peakRow('uv');
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
const burnMins = peakUvRow && peakUvRow.uv > 0
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
: null;
return [
{
icon: '🌤',
label: 'Comfortable window',
value: comfortWindow
? `${hhmm(comfortWindow.start.iso)} ${hhmm(comfortWindow.end.iso)}`
: 'No comfortable window',
alert: !comfortWindow,
},
{
icon: '🌡',
label: 'Peak felt temp',
value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-',
alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)),
},
...(burnMins ? [{
icon: '☀',
label: 'UV burn time (peak)',
value: burnMins,
alert: !!(peakUvRow && peakUvRow.uv >= 6),
}] : []),
rainItem,
{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
},
];
}