Restructure
New Journal entries
Colour tabs update with wind
This commit is contained in:
fraxle
2026-08-31 15:12:45 +01:00
parent 47818fba33
commit 71860b9dd9
40 changed files with 3430 additions and 443 deletions
+155 -15
View File
@@ -1003,7 +1003,7 @@ function bestRun(arr, pred) {
// ------------------------------------------------------------------------
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist
const PICK_MAX_DAYS = 7; // the whole week, when the whole week is good
// Said when no day qualifies, phrased for what was actually being judged -
// "no settled outdoor windows" is meaningless when the panel was reading a
@@ -1116,19 +1116,147 @@ function comfortableHour(r, crit) {
return true;
}
// Each measure's own 0-1 score for one hour, keyed so the same numbers can be
// both averaged into a quality score and read back afterwards to say WHICH
// measure cost the day its marks.
function hourParts(r, crit) {
return {
thermal: plateauScore(r[crit.field], crit.pl),
rain: bandScore(r.precipProb ?? 0, BAND_RAIN),
gust: bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST),
soil: bandScore(r.soilM, BAND_SOIL),
};
}
// How good an hour is WITHIN the bands, 0-1. Weighted by how much each
// measure actually decides whether the day was worth going out for.
function hourQuality(r, crit) {
const parts = [
[plateauScore(r[crit.field], crit.pl), crit.w.thermal],
[bandScore(r.precipProb ?? 0, BAND_RAIN), crit.w.rain],
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), crit.w.gust],
[bandScore(r.soilM, BAND_SOIL), crit.w.soil],
].filter(([v, w]) => v != null && w > 0);
const p = hourParts(r, crit);
const parts = Object.keys(p)
.map(k => [p[k], crit.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;
}
// ── Why a day scored what it scored ──────────────────────────────────────
// A calendar icon on every row said nothing the day name had not already
// said. The icon now names the single biggest reason the day is not a
// hundred, so a column of scores can be read without opening each day: three
// windy days and one hot one is a different week from four wet ones.
//
// The icon is always a weather influence - heat, cold, rain, wind, wet ground
// - never "the window was short". Length is a symptom: the window is short
// BECAUSE the morning was cold or it rained after three, and the times beside
// the icon already say how long it is. So when marks are lost to length, the
// hours outside the window are asked what ruled them out and that is what the
// row shows.
// One vocabulary for both routes to a cause - hours inside the window scoring
// poorly, and hours outside it being ruled out - so the same influence always
// reads the same way whichever way it cost the day marks.
const SHORTFALL_ICON = {
rain: { icon: '🌧️', name: 'Rain' },
gust: { icon: '💨', name: 'Wind' },
soil: { icon: '💧', name: 'Wet ground' },
hot: { icon: '🔥', name: 'Heat' },
cold: { icon: '🥶', name: 'Cold' },
none: { icon: '✅', name: 'Nothing much against it' },
};
// The tooltip is the influence and what it cost, in points of the day's own
// score: "Heat = 34%" says both what is wrong and how much it matters, so a
// 90% row and a 60% row with the same icon are not read as the same warning.
const causeTip = (cause) =>
cause.cost ? `${cause.name} = ${cause.cost}%` : cause.name;
// Why one hour failed to qualify. An hour can fail on more than one count -
// a cold wet morning is both - so every breach is returned and the day is
// decided on which comes up most, not on which happens to be tested first.
function failReasons(r, crit) {
const out = [];
const t = r[crit.field];
if (t == null) return out;
if (t < crit.pl.min) out.push('cold');
if (t > crit.pl.max) out.push('hot');
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');
return out;
}
// What kept the rest of the day out of the window. Returns null when those
// hours give no reason at all - dark hours for a daylight profile, or a gap
// in the data - so the caller can fall back to the in-window measures rather
// than print a non-answer.
function shortBecause(c, crit) {
const inRun = new Set(c.run.map(r => r.iso));
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;
}
// Cost is attached by the caller: those hours were ruled out entirely, so
// what this influence cost is the whole of the length shortfall.
const worst = Object.keys(tally).sort((a, b) => tally[b] - tally[a])[0];
return worst ? SHORTFALL_ICON[worst] : null;
}
// Below this the day is as good as the bands allow and picking a "cause"
// would be inventing one: at five points the strongest complaint about the
// day is worth a twentieth of its score, which is noise, not a reason to
// stay in. Set higher and genuinely breezy days went out labelled faultless.
const SHORTFALL_FLOOR = 0.05;
// Marks lost, as whole points of the 0-100 score the row already shows, so
// 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 = {};
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 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)]);
}
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 };
}
return { ...SHORTFALL_ICON[worst], cost };
}
export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
if (!weekDays || weekDays.length === 0) return [];
const crit = bestDayCriteria(profile, variant);
@@ -1160,14 +1288,16 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length;
candidates.push({
len: dayBest.len, quality, key: day.key,
len: dayBest.len, quality, key: day.key, run,
from: rows[dayBest.start], to: rows[dayBest.end],
// What the window is measured AGAINST: the daylight for a profile that
// 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
// only counts daylight hours, otherwise the whole day. Using daylight
// 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.
usable: crit.daylightOnly ? rows.filter(r => r.elev > 0).length : rows.length,
usableRows: crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows,
});
}
// Nothing qualifying is itself worth saying - an empty panel reads as a bug,
@@ -1192,12 +1322,19 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
// window of perfect ones are both worth knowing about, and letting either
// half dominate hides one of them.
for (const c of candidates) {
const span = c.usable > 0 ? Math.min(c.len / c.usable, 1) : 0;
const span = c.usableRows.length > 0
? Math.min(c.len / c.usableRows.length, 1)
: 0;
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);
}
// Only the top few are kept: past four rows the panel stops being a
// shortlist and turns back into the day tabs.
// 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.
const shown = [...candidates]
.sort((a, b) => b.score - a.score)
.slice(0, PICK_MAX_DAYS)
@@ -1220,12 +1357,15 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
// 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.usable > 0 && c.len >= c.usable - 1
const window = c.usableRows.length > 0 && c.len >= c.usableRows.length - 1
? 'all day'
: `${hh(c.from.iso)} ${hh(endIso)}`;
return {
icon: '📅',
// 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),
label: dayName,
value: window,
// Rows stay in date order - the order you plan in - so the ranking is