Cycling hazards (new feature)

Added cyclingRisk() in compute.js, which scores each hour 0, 1 or 2 (fine, care, risky) from gusts, wind, heavy rain, snow, ice, fog and lightning.
Shows a ⚠ tag beside the hour in the table and the forecast cards, amber for care and red for risky, and it opens the event-tag popup with advice.
At a glance now has a “Ride with care / Risky to ride” row, and the best cycling window leaves out hazard hours.
When hours are grouped into 2h/3h/4h rows, bikeRisk takes the worst hour, and the reasons come from that same hour.

Felt wind

Added feltWindMs() in utils.js, which compresses strong wind on a log curve so SunSoak and Shade (felt) no longer over-chill in gales.
“Why it feels like” uses the same softened wind, so its parts still add up to the shown value.

Week Ahead / best day scoring

Every profile is now judged over waking hours (7am–10pm) instead of daylight only, and home, vehicle, pets, airport, camping, festival and driver run round the clock.
Cycling, urban walking and airport each get their own scoring. Airport weights wind and fog most, and urban walking uses a cooler comfort range.
Leisure variants such as picnic, beach and dog walk now get a sunshine weight from cloud cover, in daylight only.
Gusts over 31 mph count three times as much in the score and in the choice of the day’s icon.
Days with no good window are still listed, with a low score and the reason shown in place of a time range.
Each day’s popup now shows its top three influences instead of one.

Simple view (cards + curve)

The temperature curve always plots hourly data now, so it keeps its shape at 2h/3h/4h.
Cards that cover several hours are centred over their whole block, and the curve draws a bracket and dashed guides over the hours each card covers.

Detailed (rotated) table styling

Cells are tighter (smaller padding and minimum widths), so more hours fit across.
The hour row has a white background with a lighter rule under the header, and the time label gets extra top padding.
Metric names and their units sit on one line, and gusts stack under the wind speed.

Toolbar / profile row

The “Quick” view button is renamed “Simple”, and the view toggle is bigger, with Edit columns dropping onto a new line on mobile.
The profile row now shows a scene thumbnail of the active profile (DayTabs.js).
In that row, the “Profile” and “Viewing” labels stack above their values, and Change is a solid 40px button in the row’s own colour.

Config / data

Dog Walking now shows the pet columns: fur surface, paw temperature and pet shade (config.js).
data/tracking.json has 14 new lines of tracking data.
This commit is contained in:
fraxle
2026-10-29 13:18:50 +00:00
parent 862f631beb
commit 71b3e8f6eb
8 changed files with 571 additions and 129 deletions
+287 -67
View File
@@ -17,7 +17,7 @@ import {
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
calcFurSurfaceTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, feltWindMs, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
@@ -96,9 +96,11 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const utci = utciApprox(Ta, TmrtStd, va, eh);
// SunSoak: same peer-reviewed polynomial, every input shifted by the
// active Solar Model (air temp via taOffset, radiation via dir/dif/glob
// factors), then the rain/snow soak penalty on top.
// factors), then the rain/snow soak penalty on top. Wind is softened
// via feltWindMs() so strong mast winds don't over-chill (see utils.js).
const vaFelt = feltWindMs(va);
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
const utciEnv = utciApprox(TaEnv, Tmrt, va, eh);
const utciEnv = utciApprox(TaEnv, Tmrt, vaFelt, eh);
const utciAdj = utciEnv + precipPenalty(precip, snow, va);
// Shade air temp: what a thermometer in this Solar Model's typical shade
// (building shadow, beach umbrella, canopy...) would read. Shares
@@ -109,7 +111,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// Shade (felt): SunSoak with the direct beam taken away — still outdoors,
// so the same wind, humidity and rain penalty apply. See
// calcShadeFeltTemp() for why this is a felt number and not the air temp.
const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, va, eh, elev, env.shade)
const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, vaFelt, eh, elev, env.shade)
+ precipPenalty(precip, snow, va);
// Derived
const compass = windCompass8(wd);
@@ -226,6 +228,13 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo;
const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo, uvArr, cloudCatArr);
hourlyRows.forEach((r, i) => { r.furSurfaceT = furTemps[i]; });
// Cycling hazard per hour - needs the previous hours' precip for ice.
hourlyRows.forEach((r, i) => {
const { level, why } = cyclingRisk(r, hourlyRows.slice(Math.max(0, i - 3), i));
r.bikeRisk = level;
r.bikeRiskWhy = why;
});
}
// Group those hourly rows into days for the day tabs.
@@ -280,6 +289,57 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
return { hourlyRows, days, utcOffsetMs };
}
// ------------------------------------------------------------------------
// cyclingRisk(r, prevRows) - How risky this hour is to take a bike out.
//
// Returns { level, why }: level 0 = fine, 1 = caution, 2 = risky; why is
// the list of hazards that set it, worst first, e.g. ['Gusts 42 mph'].
//
// Thresholds (mph = m/s x 2.237):
// Gusts >= 30 caution (crosswind shove) >= 40 risky
// Wind >= 20 caution (steering effort) >= 28 risky
// Rain >= 2 mm/h caution (spray, braking) >= 6 mm/h risky
// Snow any caution >= 0.2 cm/h risky
// Ice surface <= 2.5 C and wet surface <= 0.5 C and wet
// Fog visibility < 1 km caution < 0.2 km risky
// Lightning LPI >= 5 caution >= 25 risky
//
// "Surface" is the colder of air and bare-soil skin temperature - on a
// clear night the road chills well below the air. "Wet" is any rain or snow
// this hour or the three before it, or near-saturated air (frost / rime).
// ------------------------------------------------------------------------
const BIKE_GUST_MPH = [30, 40];
const BIKE_WIND_MPH = [20, 28];
const BIKE_RAIN_MMH = [2, 6];
const BIKE_SNOW_CMH = 0.2;
const BIKE_ICE_C = [2.5, 0.5];
const BIKE_FOG_KM = [1, 0.2];
const BIKE_LPI = [5, 25];
function cyclingRisk(r, prevRows = []) {
const hits = []; // [level, text]
const gust = (r.gust ?? r.va ?? 0) * 2.237;
const wind = (r.va ?? 0) * 2.237;
if (gust >= BIKE_GUST_MPH[0]) hits.push([gust >= BIKE_GUST_MPH[1] ? 2 : 1, `Gusts ${Math.round(gust)} mph`]);
else if (wind >= BIKE_WIND_MPH[0]) hits.push([wind >= BIKE_WIND_MPH[1] ? 2 : 1, `Wind ${Math.round(wind)} mph`]);
const precip = r.precip ?? 0;
const snow = r.snow ?? 0;
if (snow > 0) hits.push([snow >= BIKE_SNOW_CMH ? 2 : 1, 'Snow']);
else if (precip >= BIKE_RAIN_MMH[0]) hits.push([precip >= BIKE_RAIN_MMH[1] ? 2 : 1, 'Heavy rain']);
const surf = Math.min(r.Ta ?? 99, r.soilT0 ?? 99);
const wet = precip > 0 || snow > 0 || (r.RH ?? 0) >= 95
|| prevRows.some(p => (p.precip ?? 0) > 0 || (p.snow ?? 0) > 0);
if (wet && surf <= BIKE_ICE_C[0]) hits.push([surf <= BIKE_ICE_C[1] ? 2 : 1, surf <= BIKE_ICE_C[1] ? 'Ice likely' : 'Possible ice']);
if (r.visKm != null && r.visKm < BIKE_FOG_KM[0]) hits.push([r.visKm < BIKE_FOG_KM[1] ? 2 : 1, 'Fog']);
if ((r.lightning ?? 0) >= BIKE_LPI[0]) hits.push([r.lightning >= BIKE_LPI[1] ? 2 : 1, 'Lightning']);
hits.sort((a, b) => b[0] - a[0]);
return { level: hits.length ? hits[0][0] : 0, why: hits.map(h => h[1]) };
}
// ------------------------------------------------------------------------
// aggregateRows(rows, interval) - Compress a day's hourly rows into
// multi-hour buckets for the table view (1h / 2h / 3h / 4h).
@@ -316,7 +376,7 @@ const AGG_MEAN = [
'furSurfaceT', 'pawT', 'petShadeT',
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
];
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust'];
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust', 'bikeRisk'];
const AGG_SUM = ['precip', 'snow'];
// Every temperature the user reads as "how hot/cold does this actually get"
// shares one rule, so a 3h row never disagrees with the columns beside it.
@@ -362,6 +422,8 @@ export function aggregateRows(rows, interval) {
AGG_MAX.forEach(f => { out[f] = maxV(nums(group, f)); });
AGG_SUM.forEach(f => { out[f] = group.reduce((a, r) => a + (r[f] || 0), 0); });
AGG_FELT.forEach(f => { out[f] = felt(nums(group, f)); });
// Cycling hazard reasons follow the bucket's worst hour.
out.bikeRiskWhy = group.reduce((w, r) => ((r.bikeRisk ?? 0) > (w.bikeRisk ?? 0) ? r : w), group[0]).bikeRiskWhy;
// Bucket span markers used by the table for "now" highlighting and for
// matching event cell-tags that fall on any hour within the bucket.
out.isoHours = group.map(r => r.iso.slice(0, 13));
@@ -392,6 +454,8 @@ 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;
// Same softened wind SunSoak uses, so the deltas still sum to utciAdj.
const vaFelt = feltWindMs(va);
// Environment-adjusted air temp (same as used in buildHourlyRows).
const TaEnv = Ta + (env ? env.taOffset : 0);
@@ -410,10 +474,10 @@ export function computeWhyFeelsLike(row, env) {
const sunAndSky = r1(utciApprox(TaEnv, Tmrt, 0, ehNeutral) - base);
// Wind: cooling effect of actual wind with solar present (RH still neutral).
const wind = r1(utciApprox(TaEnv, Tmrt, va, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral));
const wind = r1(utciApprox(TaEnv, Tmrt, vaFelt, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral));
// Humidity: actual vapour pressure vs neutral RH 50%, with solar and wind.
const humidity = r1(utciApprox(TaEnv, Tmrt, va, eh) - utciApprox(TaEnv, Tmrt, va, ehNeutral));
const humidity = r1(utciApprox(TaEnv, Tmrt, vaFelt, eh) - utciApprox(TaEnv, Tmrt, vaFelt, ehNeutral));
// Environment modifier - explicit temperature offset from the env config.
const environment = env ? r1(env.taOffset) : 0;
@@ -815,9 +879,23 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
// ── Activities - running / cycling ─────────────────────────────────────
if (variant === 'running' || variant === 'cycling') {
const isBike = variant === 'cycling';
// Any hazard hour (care or risky) is excluded from the "best" cycling
// window, so it can never overlap the hazard row below.
const coolWins = allWindows(dayRows, r =>
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30 && !(isBike && r.bikeRisk >= 1)
);
// Cycling hazards across the whole day - commutes run in the dark too.
const bikeWins = isBike ? allWindows(todayRows, r => r.bikeRisk >= 1) : [];
const worstBike = isBike ? peakRow('bikeRisk') : null;
const bikeItem = isBike ? [{
icon: '⚠️',
label: worstBike?.bikeRisk >= 2 ? 'Risky to ride' : worstBike?.bikeRisk >= 1 ? 'Ride with care' : 'Ride hazards',
value: formatWindows(bikeWins) ?? 'No hazards today',
sub: worstBike?.bikeRisk >= 1 ? worstBike.bikeRiskWhy.join(', ') : null,
alert: worstBike?.bikeRisk >= 2,
grp: 'felt',
}] : [];
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));
@@ -830,10 +908,11 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
{
icon: '⏱',
label: `Best ${variant} window`,
value: formatWindows(coolWins) ?? 'No cool window today',
value: formatWindows(coolWins) ?? (isBike && bikeWins.length ? 'No safe window today' : 'No cool window today'),
alert: coolWins.length === 0,
grp: 'felt',
},
...bikeItem,
...(show('precipProb') ? [rainItem] : []),
...lightningItem,
...(show('burn') && burnMins ? [{
@@ -1000,7 +1079,9 @@ function bestRun(arr, pred) {
// actually answers "would I enjoy being outside" - unlike vehicleT or
// indoorT, where a "best day" framing would be meaningless.
//
// Daylight hours only, and hours already past today are skipped, so a warm
// Waking hours only (WAKING_HOURS, 7am-10pm, dark or not - the winter
// commute and evening walk count; isAllDay profiles run 24 hours), and
// hours already past today are skipped, so a warm
// morning that has been and gone can't win.
// ------------------------------------------------------------------------
@@ -1044,9 +1125,60 @@ const SUNSOAK = plateau(UTCI_BANDS);
// in the table - reading the plateau off the same array keeps the panel and
// the cell colours in step there too.
const PET_PLATEAU = plateau(PET_BANDS);
const BAND_GUST = { ideal: 15, limit: 25 }; // mph
// Walking in town (to work, to the shops) is not lounging outside: people are
// dressed for the weather and moving, so a cool morning is fine. Full marks
// from 10 °C (bottom of Chilly) to the top of Warm, ramping to zero at 0 °C
// and at the top of Caution. Heat keeps the SunSoak ceiling - hot pavements
// are as bad for a walk as for a day out.
const URBAN_WALK = {
min: 0,
flatLo: bandMax(UTCI_BANDS, 'Cold'), // 10 - score 1
flatHi: SUNSOAK.flatHi,
max: SUNSOAK.max,
};
// Hours every profile is judged over, as local clock hours [from, to):
// 7am-10pm covers the commute, the day and the evening, dark or not.
// Daylight alone dropped winter mornings and evenings people are out in;
// round the clock let 3am windows win.
const WAKING_HOURS = { from: 7, to: 22 };
// Profiles and outdoors variants judged round the clock instead: travel,
// driving, the house, pets and overnight stays all happen at any hour.
const ALL_DAY_PROFILES = ['home', 'vehicle', 'pets'];
const ALL_DAY_VARIANTS = ['airport', 'camping', 'festival', 'driver'];
const isAllDay = (profile, variant) =>
ALL_DAY_PROFILES.includes(profile) ||
(profile === 'outdoors' && ALL_DAY_VARIANTS.includes(variant));
const BAND_GUST = { ideal: 15, limit: 25 }; // mph
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy
const BAND_BIKE = { ideal: 0, limit: 2 }; // cyclingRisk level - fine to risky
const BAND_FOG = { ideal: 5, limit: 1 }; // visibility km - clear to fog (<1 km)
const BAND_SUN = { ideal: 20, limit: 90 }; // cloud cover % - sunny to overcast
// Sunshine. Thermal already credits the sun's warmth, but inside the comfort
// plateau a grey 18 °C and a bright 18 °C scored the same - and for a picnic,
// a festival or a dog walk the bright one is the better day. Leisure variants
// listed here move SUN_WEIGHT from thermal onto cloud cover. Quality only:
// cloud never breaks a window, it just costs it marks. Running is left out
// (overcast is often better for it), as are the travel, vehicle, indoor and
// pet profiles, where sunshine is no part of the answer.
const SUNNY_VARIANTS = ['dogwalk', 'cycling', 'hiking', 'photography', 'naturist',
'park', 'beach', 'events', 'festival', 'camping'];
const SUN_WEIGHT = 0.15;
// Severe gusts. Past BAND_GUST.limit an hour already scores zero on gust, but
// at its normal weight a gale cost a day less than being one degree under the
// SunSoak floor. Above GUST_SEVERE (31 mph, near-gale) the wind is THE story,
// so its weight in the score and its vote for the day's icon are multiplied.
const GUST_SEVERE = 31; // mph
const GUST_SEVERE_MULT = 3; // weight / icon-vote multiplier above it
const gustMph = (r) => (r.gust ?? r.va ?? 0) * 2.237;
const isSevere = (r) => gustMph(r) > GUST_SEVERE;
// The profile's measure weights for one hour, with gust boosted when severe.
function partWeights(r, crit) {
return isSevere(r) ? { ...crit.w, gust: crit.w.gust * GUST_SEVERE_MULT } : crit.w;
}
// 1 at the ideal end, 0 at the limit, clamped. Direction is taken from the
// two numbers, so it reads the same whether lower or higher is better.
@@ -1084,25 +1216,58 @@ function plateauScore(v, pl) {
// answer to "when should I drive", so vehicle and indoor windows run around
// the clock.
function bestDayCriteria(profile, variant) {
// Cycling adds the bike-hazard score (bikeRisk): any hazard hour breaks
// the window, matching "Best cycling window" in At a glance. Inside a
// window bike always scores full, so it only weighs on no-window days -
// kept small (soil's share) so it doesn't inflate window scores.
if (profile === 'outdoors' && variant === 'cycling') {
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0, bike: 0.15 } };
}
// Town walking: paving drains and dries fast, so wet ground is dropped;
// rain and wind are what spoil a walk, so they carry more of the score.
if (profile === 'outdoors' && variant === 'urban') {
return { field: 'utciAdj', pl: URBAN_WALK, daylightOnly: false, hours: WAKING_HOURS,
w: { thermal: 0.35, rain: 0.40, gust: 0.25, soil: 0 } };
}
// Airport / travel: wind is what delays and diverts flights and makes
// ferries and trains unpleasant, so it carries the most weight, with fog
// (low-visibility delays) next. Ground state is irrelevant on runways and
// platforms, and travellers are dressed for the trip, so the walking
// comfort range is used rather than SunSoak.
if (profile === 'outdoors' && variant === 'airport') {
return { field: 'utciAdj', pl: URBAN_WALK, daylightOnly: false, hours: WAKING_HOURS,
w: { thermal: 0.15, rain: 0.20, gust: 0.40, soil: 0, fog: 0.25 } };
}
// Every profile is judged over WAKING_HOURS; daylightOnly stays available
// but is off everywhere.
const { mainField } = deriveProfileMain(profile, variant);
switch (mainField) {
case 'vehicleT':
return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false,
return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } };
case 'indoorT':
return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false,
return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
w: { thermal: 1, rain: 0, gust: 0, soil: 0 } };
case 'furSurfaceT':
return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: true,
return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: false, hours: WAKING_HOURS,
w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } };
default:
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: true,
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } };
}
}
// True when the hour falls inside the profile's clock-hour range, if it has one.
function inHours(r, crit) {
if (!crit.hours) return true;
const h = parseInt(r.iso.slice(11, 13), 10);
return h >= crit.hours.from && h < crit.hours.to;
}
function comfortableHour(r, crit) {
if (crit.daylightOnly && !(r.elev > 0)) return false;
if (!inHours(r, crit)) return false;
const t = r[crit.field];
if (t == null) return false;
if (t < crit.pl.min || t > crit.pl.max) return false;
@@ -1115,6 +1280,9 @@ function comfortableHour(r, crit) {
// would silently empty the panel everywhere. Only the wet end excludes: dust
// -dry ground is a poor day out for the garden, not for the person on it.
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
if (crit.w.bike && r.bikeRisk >= 1) return false;
// Visibility can be missing too - treat that as passing, as with soil.
if (crit.w.fog && r.visKm != null && r.visKm < BAND_FOG.limit) return false;
return true;
}
@@ -1127,6 +1295,11 @@ function hourParts(r, crit) {
rain: bandScore(r.precipProb ?? 0, BAND_RAIN),
gust: bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST),
soil: bandScore(r.soilM, BAND_SOIL),
bike: bandScore(r.bikeRisk ?? 0, BAND_BIKE),
fog: bandScore(r.visKm, BAND_FOG),
// Daylight only - null after dark drops it from the average, so evening
// hours aren't marked down for a sky nobody can see.
sun: r.elev > 0 ? bandScore(r.cc, BAND_SUN) : null,
};
}
@@ -1134,8 +1307,9 @@ function hourParts(r, crit) {
// measure actually decides whether the day was worth going out for.
function hourQuality(r, crit) {
const p = hourParts(r, crit);
const w = partWeights(r, crit);
const parts = Object.keys(p)
.map(k => [p[k], crit.w[k]])
.map(k => [p[k], w[k]])
.filter(([v, w]) => v != null && w > 0);
const wsum = parts.reduce((s, [, w]) => s + w, 0);
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
@@ -1162,6 +1336,9 @@ const SHORTFALL_ICON = {
soil: { icon: '💧', name: 'Wet ground' },
hot: { icon: '🔥', name: 'Heat' },
cold: { icon: '🥶', name: 'Cold' },
bike: { icon: '⚠️', name: 'Unsafe to ride' },
fog: { icon: '🌫️', name: 'Fog' },
sun: { icon: '☁️', name: 'Cloudy' },
none: { icon: '✅', name: 'Nothing much against it' },
};
@@ -1183,6 +1360,8 @@ function failReasons(r, crit) {
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) out.push('rain');
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) out.push('gust');
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) out.push('soil');
if (crit.w.bike && r.bikeRisk >= 1) out.push('bike');
if (crit.w.fog && r.visKm != null && r.visKm < BAND_FOG.limit) out.push('fog');
return out;
}
@@ -1195,7 +1374,11 @@ function shortBecause(c, crit) {
const tally = {};
for (const r of c.usableRows) {
if (inRun.has(r.iso)) continue;
for (const k of failReasons(r, crit)) tally[k] = (tally[k] ?? 0) + 1;
// A severe-gust hour outvotes a marginal cold or hot one.
for (const k of failReasons(r, crit)) {
const vote = (k === 'gust' && isSevere(r)) ? GUST_SEVERE_MULT : 1;
tally[k] = (tally[k] ?? 0) + vote;
}
}
// Cost is attached by the caller: those hours were ruled out entirely, so
// what this influence cost is the whole of the length shortfall.
@@ -1213,55 +1396,69 @@ const SHORTFALL_FLOOR = 0.05;
// the tooltip's number and the bar's number are in the same units.
const pts = (frac) => Math.round(frac * 100);
function limitingFactor(c, crit, span) {
const run = c.run;
// Mean score per measure across the run, so one bad hour in nine can't
// name the day.
const sums = {}, counts = {};
// Returns up to three influences ({ icon, name, cost }), worst first.
function limitingFactors(c, crit, span) {
// A day with no window has no run to read the measures from, so its whole
// usable day stands in for it.
const run = c.run.length ? c.run : c.usableRows;
// Marks each measure lost per hour, averaged across the run so one bad hour
// in nine can't name the day. Per-hour weights (partWeights) so a severe
// gust costs here exactly what it cost in hourQuality.
const lostSum = {};
let hours = 0;
for (const r of run) {
const p = hourParts(r, crit);
for (const k of Object.keys(p)) {
if (p[k] == null || !crit.w[k]) continue;
sums[k] = (sums[k] ?? 0) + p[k];
counts[k] = (counts[k] ?? 0) + 1;
}
const w = partWeights(r, crit);
const keys = Object.keys(p).filter(k => p[k] != null && w[k]);
const wsum = keys.reduce((s, k) => s + w[k], 0);
if (!wsum) continue;
hours++;
for (const k of keys) lostSum[k] = (lostSum[k] ?? 0) + (w[k] / wsum) * (1 - p[k]);
}
const wsum = Object.keys(counts).reduce((s, k) => s + crit.w[k], 0);
// Marks lost, in points of the final score: quality is half of it, split
// between the measures by weight; window length is the other half.
const lost = [['short', 0.5 * (1 - span)]];
for (const k of Object.keys(counts)) {
const mean = sums[k] / counts[k];
lost.push([k, 0.5 * (crit.w[k] / wsum) * (1 - mean)]);
}
for (const k of Object.keys(lostSum)) lost.push([k, 0.5 * lostSum[k] / hours]);
lost.sort((a, b) => b[1] - a[1]);
if (lost[0][1] < SHORTFALL_FLOOR) return SHORTFALL_ICON.none;
// Length lost the most marks: name what ruled the other hours out. If they
// can't say, drop through to whichever measure was weakest inside the
// window - still an influence, which is the whole point of the icon.
if (lost[0][0] === 'short') {
const because = shortBecause(c, crit);
if (because) return { ...because, cost: pts(lost[0][1]) };
lost.shift();
if (lost.length === 0) return SHORTFALL_ICON.none;
}
const [worst, amount] = lost[0];
const cost = pts(amount);
// Thermal has two opposite causes and they want opposite icons - a cold
// morning and a scorching afternoon are not the same warning.
if (worst === 'thermal') {
const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length;
return { ...(meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold), cost };
const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length;
const thermalIcon = meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold;
// Turn each measure's lost marks into a named influence. Length lost names
// what ruled the other hours out; if they can't say it is dropped, so the
// list only ever holds weather influences.
const byName = {};
for (const [k, amount] of lost) {
if (amount < SHORTFALL_FLOOR) continue;
const f = k === 'short' ? shortBecause(c, crit)
: k === 'thermal' ? thermalIcon
: SHORTFALL_ICON[k];
if (!f) continue;
// Length blamed on wind and wind inside the window are one influence.
byName[f.name] = byName[f.name]
? { ...byName[f.name], amount: byName[f.name].amount + amount }
: { ...f, amount };
}
return { ...SHORTFALL_ICON[worst], cost };
// Top three by marks lost, worst first - the first is the row's icon.
const top = Object.values(byName)
.sort((a, b) => b.amount - a.amount)
.slice(0, 3)
.map(({ amount, ...f }) => ({ ...f, cost: pts(amount) }));
return top.length ? top : [SHORTFALL_ICON.none];
}
export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
if (!weekDays || weekDays.length === 0) return [];
const crit = bestDayCriteria(profile, variant);
// Round-the-clock profiles drop the waking-hours limit.
if (isAllDay(profile, variant)) crit.hours = null;
// Sun-seeking leisure variants trade some thermal weight for sunshine.
if (profile === 'outdoors' && SUNNY_VARIANTS.includes(variant)) {
crit.w = { ...crit.w, thermal: crit.w.thermal - SUN_WEIGHT, sun: SUN_WEIGHT };
}
const candidates = [];
for (const day of weekDays.slice(0, 7)) {
@@ -1281,17 +1478,29 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
start = -1; len = 0;
}
}
if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;
// Hours the day is judged against (daylight only for outdoor profiles,
// waking hours for town walking).
const usableRows = (crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows)
.filter(r => inHours(r, crit));
// Nothing left to judge (e.g. today after dark) - no honest score exists.
if (usableRows.length === 0) continue;
// A day with no window of PICK_MIN_HOURS is still shown, with a low score
// and the reason it failed - hiding it made the week look shorter than it is.
const hasWindow = dayBest && dayBest.len >= PICK_MIN_HOURS;
// Average quality across the run. Length alone can't separate days: in a
// temperate summer week half of them qualify dawn to dusk, and picking
// arbitrarily among those is a coin toss.
const run = rows.slice(dayBest.start, dayBest.end + 1);
const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length;
// arbitrarily among those is a coin toss. With no window the whole
// usable day is averaged instead.
const run = hasWindow ? rows.slice(dayBest.start, dayBest.end + 1) : [];
const qRows = hasWindow ? run : usableRows;
const quality = qRows.reduce((s, r) => s + hourQuality(r, crit), 0) / qRows.length;
candidates.push({
len: dayBest.len, quality, key: day.key, run,
from: rows[dayBest.start], to: rows[dayBest.end],
len: hasWindow ? dayBest.len : 0, quality, key: day.key, run, hasWindow,
from: hasWindow ? rows[dayBest.start] : null,
to: hasWindow ? rows[dayBest.end] : null,
// What the window is measured AGAINST - and, for the hours outside the
// run, the evidence for WHY it stopped where it did: the daylight for a
// profile that
@@ -1299,11 +1508,11 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
// for a round-the-clock profile would let a 14-hour overnight window
// score over 100% of a 16-hour day and print "all day" for a spell that
// ends at breakfast.
usableRows: crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows,
usableRows,
});
}
// Nothing qualifying is itself worth saying - an empty panel reads as a bug,
// and "don't bother this week" is a real answer to the question being asked.
// No day left anything to judge (e.g. only dark hours remain) - an empty
// panel reads as a bug, so say so.
if (candidates.length === 0) {
return [{
icon: crit.field === 'utciAdj' ? '🌧️' : '🚫',
@@ -1330,13 +1539,14 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
// Same span the score used, so the icon can never blame the weather for
// marks that were actually lost to a short window.
c.cause = limitingFactor(c, crit, span);
c.causes = limitingFactors(c, crit, span);
c.cause = c.causes[0];
}
// A settled week really can have seven good days, and cutting it to a
// shortlist there would say the opposite of what the forecast shows. Only
// days that actually qualify get a row, so the panel still stays short in
// an unsettled week - the cap is the week itself, not an arbitrary four.
// shortlist there would say the opposite of what the forecast shows. Every
// day of the week gets a row - poor days with a low bar and their reason -
// so the cap is the week itself, not an arbitrary four.
const shown = [...candidates]
.sort((a, b) => b.score - a.score)
.slice(0, PICK_MAX_DAYS)
@@ -1355,19 +1565,29 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
// The end row is the last comfortable hour, so the window runs to the end
// of it - 9am-12pm means 9:00 up to 12:59.
const endIso = `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;
// c.to is null on a day with no window, which has no end time to build.
const endIso = c.to
? `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`
: null;
// A run spanning nearly all the daylight is better said than shown: printing
// "6am - 9pm" makes the reader parse a time range to learn "all day".
const window = c.usableRows.length > 0 && c.len >= c.usableRows.length - 1
? 'all day'
: `${hh(c.from.iso)} – ${hh(endIso)}`;
// A day with no window shows its reason in words instead of a time range.
// Kept to the cause name alone: anything longer than "10am – 4pm" wraps
// onto a second line in the narrow rail.
const window = !c.hasWindow
? c.cause.name
: c.len >= c.usableRows.length - 1
? 'all day'
: `${hh(c.from.iso)} – ${hh(endIso)}`;
return {
// Not a calendar - the row already says which day. The icon carries the
// one thing the score cannot: what is holding the day back.
icon: c.cause.icon,
iconTitle: causeTip(c.cause),
// The top three influences for the row's popup, worst first.
factors: c.causes.map(f => ({ icon: f.icon, text: causeTip(f) })),
label: dayName,
value: window,
// Rows stay in date order - the order you plan in - so the ranking is