5.4.0
Restructure New Journal entries Colour tabs update with wind
This commit is contained in:
+155
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user