5.0.3
The Week Ahead - Best Times Out
This commit is contained in:
@@ -203,7 +203,11 @@
|
|||||||
min-height: 12px;
|
min-height: 12px;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.1;
|
/* Fixed 12.1px line box = the secondary number's own line box (11px x 1.1),
|
||||||
|
so the smaller label text centres on the number rather than riding above
|
||||||
|
it. Stated in px, not a ratio, so it holds when the mobile media query
|
||||||
|
drops the label's font-size. */
|
||||||
|
line-height: 12.1px;
|
||||||
color: #9a7d5a;
|
color: #9a7d5a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1537,6 +1537,54 @@
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Week rows carry a score bar, so they are a two-line grid rather than the
|
||||||
|
single flex line the glance rows use: day and window on top, bar beneath. */
|
||||||
|
.insight-panel--week .insight-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"icon label value"
|
||||||
|
"icon score score";
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.insight-panel--week .insight-icon { grid-area: icon; }
|
||||||
|
.insight-panel--week .insight-label { grid-area: label; }
|
||||||
|
.insight-panel--week .insight-value { grid-area: value; }
|
||||||
|
|
||||||
|
/* How good the day is out of 100 — the bar does the comparing, the number
|
||||||
|
settles close calls. */
|
||||||
|
.week-score {
|
||||||
|
grid-area: score;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-score__track {
|
||||||
|
flex: 1;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #ede4cc;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colour is set inline per row - it tracks the score itself (red at 0 through
|
||||||
|
amber to green at 100), so it can't live in a static rule. */
|
||||||
|
.week-score__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-score__pct {
|
||||||
|
font-family: JetBrains Mono, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8a8375;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Pro: Export day data ──────────────────────────────────────────────── */
|
/* ── Pro: Export day data ──────────────────────────────────────────────── */
|
||||||
.export-panel {
|
.export-panel {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -1618,6 +1666,16 @@
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sits under the panel title to say what the listed days are being judged on
|
||||||
|
- the rows themselves are just dates and windows. */
|
||||||
|
.insight-panel-sub {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a8375;
|
||||||
|
text-align: center;
|
||||||
|
margin: -6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.insight-thermal-cat {
|
.insight-thermal-cat {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
@@ -1772,6 +1830,15 @@
|
|||||||
row-gap: 1px;
|
row-gap: 1px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
/* The rail is only 240px wide, so the window drops under the day name and
|
||||||
|
the score bar takes a third line rather than sharing one. */
|
||||||
|
.insight-panel--week .insight-row {
|
||||||
|
grid-template-areas:
|
||||||
|
"icon label"
|
||||||
|
"icon value"
|
||||||
|
"icon score";
|
||||||
|
row-gap: 3px;
|
||||||
|
}
|
||||||
.insight-panel--glance .insight-icon,
|
.insight-panel--glance .insight-icon,
|
||||||
.insight-panel--week .insight-icon {
|
.insight-panel--week .insight-icon {
|
||||||
grid-area: icon;
|
grid-area: icon;
|
||||||
|
|||||||
+17
-9
@@ -29,7 +29,7 @@ import {
|
|||||||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||||||
VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES,
|
VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES,
|
||||||
FUR_COLORS,
|
FUR_COLORS,
|
||||||
confidenceBand, moonGlyph, skyFillForElev,
|
confidenceBand, moonGlyph, skyFillForElev, titleCaseText, scoreFillColor,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
|
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
|
||||||
import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js';
|
import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js';
|
||||||
@@ -40,7 +40,7 @@ import { ConfigPanel } from './components/ConfigPanel.js';
|
|||||||
import { WelcomeModal } from './components/WelcomeModal.js';
|
import { WelcomeModal } from './components/WelcomeModal.js';
|
||||||
import { RestoreModal } from './components/RestoreModal.js';
|
import { RestoreModal } from './components/RestoreModal.js';
|
||||||
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
||||||
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
|
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel } from './compute.js';
|
||||||
import { solarElevationDeg } from './physics.js';
|
import { solarElevationDeg } from './physics.js';
|
||||||
import { exportDayXls } from './export.js';
|
import { exportDayXls } from './export.js';
|
||||||
|
|
||||||
@@ -534,7 +534,7 @@ export function UTCIForecast() {
|
|||||||
);
|
);
|
||||||
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
|
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
|
||||||
// rendered in its own "The Week Ahead" box beneath it.
|
// rendered in its own "The Week Ahead" box beneath it.
|
||||||
const weekAhead = computeBestDay(days, activeProfile, outdoorsVariant, nowLocalISO);
|
const weekAhead = computeBestDay(days, nowLocalISO);
|
||||||
|
|
||||||
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
||||||
const glanceDate = (() => {
|
const glanceDate = (() => {
|
||||||
@@ -1582,8 +1582,8 @@ export function UTCIForecast() {
|
|||||||
const renderRow = ({ icon, label, value, alert, grp }, keyPrefix = '') => html`
|
const renderRow = ({ icon, label, value, alert, grp }, keyPrefix = '') => html`
|
||||||
<div class=${`insight-row${alert ? ' insight-row--alert' : ''}${grp ? ` insight-row--${grp}` : ''}`} key=${`${keyPrefix}${label}`}>
|
<div class=${`insight-row${alert ? ' insight-row--alert' : ''}${grp ? ` insight-row--${grp}` : ''}`} key=${`${keyPrefix}${label}`}>
|
||||||
<span class="insight-icon">${icon}</span>
|
<span class="insight-icon">${icon}</span>
|
||||||
<span class="insight-label">${label}</span>
|
<span class="insight-label">${titleCaseText(label)}</span>
|
||||||
<span class="insight-value">${value}</span>
|
<span class="insight-value">${titleCaseText(value)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
// Only the outdoors profile reorders items around the heat block
|
// Only the outdoors profile reorders items around the heat block
|
||||||
// (it has a 'Peak felt temp' anchor). Other profiles render in order.
|
// (it has a 'Peak felt temp' anchor). Other profiles render in order.
|
||||||
@@ -1612,11 +1612,19 @@ export function UTCIForecast() {
|
|||||||
${weekAhead.length > 0 && html`
|
${weekAhead.length > 0 && html`
|
||||||
<div class="insight-panel insight-panel--week">
|
<div class="insight-panel insight-panel--week">
|
||||||
<div class="insight-panel-title">The Week Ahead</div>
|
<div class="insight-panel-title">The Week Ahead</div>
|
||||||
${weekAhead.map(({ icon, label, value, grp }) => html`
|
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
|
||||||
<div class=${`insight-row${grp ? ` insight-row--${grp}` : ''}`} key=${label}>
|
${weekAhead.map(({ icon, label, value, score }) => html`
|
||||||
|
<div class="insight-row" key=${label}>
|
||||||
<span class="insight-icon">${icon}</span>
|
<span class="insight-icon">${icon}</span>
|
||||||
<span class="insight-label">${label}</span>
|
<span class="insight-label">${titleCaseText(label)}</span>
|
||||||
<span class="insight-value">${value}</span>
|
<span class="insight-value">${titleCaseText(value)}</span>
|
||||||
|
${score != null && html`
|
||||||
|
<span class="week-score" title=${`${score}% of an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher`}>
|
||||||
|
<span class="week-score__track">
|
||||||
|
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
|
||||||
|
</span>
|
||||||
|
<span class="week-score__pct">${score}%</span>
|
||||||
|
</span>`}
|
||||||
</div>`)}
|
</div>`)}
|
||||||
</div>`}
|
</div>`}
|
||||||
${visible.length > 0 && html`
|
${visible.length > 0 && html`
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ export function DayTabs({
|
|||||||
const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
|
const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
|
||||||
const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
|
const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
|
||||||
|
|
||||||
// Vehicle/Driver has no secondary field — the cabin number already
|
// Secondary row under each hi/lo: Shade for SunSoak, Managed for
|
||||||
// reflects the selected vehicle type, ventilation and speed, so the
|
// indoor, Pet Shade for pets, and plain air temperature for
|
||||||
// tab shows that alone rather than a second reference figure.
|
// Vehicle/Driver so the outside-vs-cabin gap is visible.
|
||||||
let shadeHi = null, shadeLo = null;
|
let shadeHi = null, shadeLo = null;
|
||||||
if (secondaryField) {
|
if (secondaryField) {
|
||||||
const secondaryVals = d.rows.map(r => r[secondaryField]).filter(v => isFinite(v));
|
const secondaryVals = d.rows.map(r => r[secondaryField]).filter(v => isFinite(v));
|
||||||
|
|||||||
+151
-47
@@ -17,7 +17,7 @@ import {
|
|||||||
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
||||||
calcFurSurfaceTempPass,
|
calcFurSurfaceTempPass,
|
||||||
} from './physics.js';
|
} from './physics.js';
|
||||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS } from './utils.js';
|
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS } from './utils.js';
|
||||||
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
|
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
|
||||||
|
|
||||||
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
|
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
|
||||||
@@ -959,13 +959,20 @@ function bestRun(arr, pred) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
// computeBestDay(weekDays, profile, variant, nowLocalISO) - the day with the
|
// computeBestDay(weekDays, nowLocalISO) - every day in the
|
||||||
// longest unbroken run of pleasant outdoor hours, for "The Week Ahead".
|
// next week with a decent unbroken run of pleasant outdoor hours, for
|
||||||
|
// "The Week Ahead".
|
||||||
//
|
//
|
||||||
// The app already works out comfort windows for the selected day, but
|
// The app already works out comfort windows for the selected day, but
|
||||||
// comparing days meant tapping through all fourteen tabs. This answers the
|
// comparing days meant tapping through all fourteen tabs. This answers the
|
||||||
// question the day tabs make you do by hand.
|
// question the day tabs make you do by hand.
|
||||||
//
|
//
|
||||||
|
// Naming a single winner was too thin to plan around: knowing Saturday is
|
||||||
|
// the best day says nothing about whether Sunday is also fine, or whether
|
||||||
|
// the whole week is a washout. So it lists the days that qualify, in date
|
||||||
|
// order, each with a 0-100 score so they can be compared at a glance
|
||||||
|
// without reordering them out of the order you plan in.
|
||||||
|
//
|
||||||
// Deliberately NOT part of computeGlanceSummary: "At a glance" describes the
|
// Deliberately NOT part of computeGlanceSummary: "At a glance" describes the
|
||||||
// one selected day, and a week-scoped line reads as a category error inside
|
// one selected day, and a week-scoped line reads as a category error inside
|
||||||
// it. It renders in its own panel below the rail instead.
|
// it. It renders in its own panel below the rail instead.
|
||||||
@@ -980,16 +987,79 @@ function bestRun(arr, pred) {
|
|||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
|
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
|
||||||
const IDEAL_UTCI = 20; // centre of the "no thermal stress" band
|
const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist
|
||||||
|
|
||||||
function comfortableHour(r) {
|
// ── The four bands ───────────────────────────────────────────────────────
|
||||||
return r.elev > 0 // daylight
|
// Each is [ideal, limit]: at the ideal end an hour scores full marks on that
|
||||||
&& r.utciAdj != null && r.utciAdj >= 9 && r.utciAdj <= 28 // no thermal stress
|
// measure, at the limit it scores zero, and past the limit the hour is not a
|
||||||
&& (r.precipProb ?? 0) < 50 // unlikely to rain on you
|
// day-out hour at all. That split is what lets the panel say "Thursday is
|
||||||
&& (r.gust ?? r.va ?? 0) * 2.237 < 32; // below near-gale
|
// fine, Saturday is better" instead of only ever answering yes or no.
|
||||||
|
//
|
||||||
|
// SunSoak is the exception: rather than one ideal point it has a flat top
|
||||||
|
// spanning the Comfortable and Warm thermal-stress bands - anywhere in there
|
||||||
|
// is a good day out and splitting hairs between 20 and 25 would be false
|
||||||
|
// precision - then falls 100% to 0% across the Cool band below and the
|
||||||
|
// Caution band above. Past those it isn't a day-out hour at all.
|
||||||
|
//
|
||||||
|
// Read off UTCI_BANDS rather than written as numbers, so the panel and the
|
||||||
|
// table's colour bands can never drift apart: today that is a 19-27 plateau
|
||||||
|
// ramping out to 15 and 32.
|
||||||
|
const bandMax = (label) => UTCI_BANDS.find(b => b.label === label).max;
|
||||||
|
const SUNSOAK = {
|
||||||
|
min: bandMax('Chilly'), // bottom of Cool - score 0
|
||||||
|
flatLo: bandMax('Cool'), // bottom of Comfortable - score 1
|
||||||
|
flatHi: bandMax('Warm'), // top of Warm - score 1
|
||||||
|
max: bandMax('Caution'), // top of Caution - score 0
|
||||||
|
};
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
function bandScore(v, { ideal, limit }) {
|
||||||
|
if (v == null) return null; // unknown - caller decides
|
||||||
|
const f = (limit - v) / (limit - ideal);
|
||||||
|
return Math.max(0, Math.min(1, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeBestDay(weekDays, profile, variant, nowLocalISO) {
|
function sunsoakScore(v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v >= SUNSOAK.flatLo && v <= SUNSOAK.flatHi) return 1;
|
||||||
|
const f = v < SUNSOAK.flatLo
|
||||||
|
? (v - SUNSOAK.min) / (SUNSOAK.flatLo - SUNSOAK.min)
|
||||||
|
: (SUNSOAK.max - v) / (SUNSOAK.max - SUNSOAK.flatHi);
|
||||||
|
return Math.max(0, Math.min(1, f));
|
||||||
|
}
|
||||||
|
|
||||||
|
function comfortableHour(r) {
|
||||||
|
if (!(r.elev > 0)) return false; // daylight only
|
||||||
|
if (r.utciAdj == null) return false;
|
||||||
|
if (r.utciAdj < SUNSOAK.min || r.utciAdj > SUNSOAK.max) return false;
|
||||||
|
if ((r.precipProb ?? 0) > BAND_RAIN.limit) return false;
|
||||||
|
if ((r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false;
|
||||||
|
// Soil comes from a separate feed and is null wherever that fetch fails or
|
||||||
|
// the model has no coverage - treat missing as passing, or a soil outage
|
||||||
|
// 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 (r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const parts = [
|
||||||
|
[sunsoakScore(r.utciAdj), 0.40],
|
||||||
|
[bandScore(r.precipProb ?? 0, BAND_RAIN), 0.25],
|
||||||
|
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), 0.20],
|
||||||
|
[bandScore(r.soilM, BAND_SOIL), 0.15],
|
||||||
|
].filter(([v]) => v != null);
|
||||||
|
const wsum = parts.reduce((s, [, w]) => s + w, 0);
|
||||||
|
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBestDay(weekDays, nowLocalISO) {
|
||||||
if (!weekDays || weekDays.length === 0) return [];
|
if (!weekDays || weekDays.length === 0) return [];
|
||||||
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
@@ -1012,59 +1082,93 @@ export function computeBestDay(weekDays, profile, variant, nowLocalISO) {
|
|||||||
}
|
}
|
||||||
if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;
|
if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;
|
||||||
|
|
||||||
// How far the run sits from an ideal ~20 °C, averaged. Length alone can't
|
// Average quality across the run. Length alone can't separate days: in a
|
||||||
// separate days: in a temperate summer week half of them run comfortable
|
// temperate summer week half of them qualify dawn to dusk, and picking
|
||||||
// from dawn to dusk, and picking arbitrarily among those is a coin toss.
|
// arbitrarily among those is a coin toss.
|
||||||
const run = rows.slice(dayBest.start, dayBest.end + 1);
|
const run = rows.slice(dayBest.start, dayBest.end + 1);
|
||||||
const miss = run.reduce((s, r) => s + Math.abs(r.utciAdj - IDEAL_UTCI), 0) / run.length;
|
const quality = run.reduce((s, r) => s + hourQuality(r), 0) / run.length;
|
||||||
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
len: dayBest.len, miss, key: day.key,
|
len: dayBest.len, quality, key: day.key,
|
||||||
from: rows[dayBest.start], to: rows[dayBest.end],
|
from: rows[dayBest.start], to: rows[dayBest.end],
|
||||||
daylight: rows.filter(r => r.elev > 0).length,
|
daylight: rows.filter(r => r.elev > 0).length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (candidates.length === 0) return [];
|
// 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.
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return [{
|
||||||
|
icon: '🌧️',
|
||||||
|
label: 'Next 7 days',
|
||||||
|
value: 'no settled outdoor windows',
|
||||||
|
alert: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
// Longest run wins, but anything within an hour of the longest counts as a
|
// Score each day out of 100 against an ideal day out rather than against
|
||||||
// tie and is settled on which day is actually the most pleasant.
|
// the rest of the week: a middling week should read as middling, not have
|
||||||
const maxLen = Math.max(...candidates.map(c => c.len));
|
// its least-bad day flattered up to full marks.
|
||||||
const best = candidates
|
//
|
||||||
.filter(c => c.len >= maxLen - 1)
|
// how much of the daylight is usable (50%)
|
||||||
.sort((a, b) => a.miss - b.miss)[0];
|
// where in the bands those hours actually sit (50%)
|
||||||
|
//
|
||||||
|
// Even between the two: a long window of merely tolerable hours and a short
|
||||||
|
// 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.daylight > 0 ? Math.min(c.len / c.daylight, 1) : 0;
|
||||||
|
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the top few are kept: past four rows the panel stops being a
|
||||||
|
// shortlist and turns back into the day tabs.
|
||||||
|
const shown = [...candidates]
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, PICK_MAX_DAYS)
|
||||||
|
.sort((a, b) => (a.key < b.key ? -1 : 1));
|
||||||
|
|
||||||
const hh = (iso) => {
|
const hh = (iso) => {
|
||||||
const h = parseInt(iso.slice(11, 13), 10);
|
const h = parseInt(iso.slice(11, 13), 10);
|
||||||
return `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`;
|
return `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`;
|
||||||
};
|
};
|
||||||
// Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back
|
|
||||||
// as UTC to stop the browser's own zone shifting the weekday.
|
|
||||||
const dayName = new Date(`${best.key}T00:00:00Z`)
|
|
||||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
||||||
|
|
||||||
// The panel title already says "The Week Ahead", so the row just names what
|
return shown.map(c => {
|
||||||
// is being judged rather than repeating the timeframe.
|
// Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back
|
||||||
const label = profile === 'outdoors' && variant
|
// as UTC to stop the browser's own zone shifting the weekday.
|
||||||
? `Best for ${deriveProfileMain(profile, variant).mainLabel}`
|
const dayName = new Date(`${c.key}T00:00:00Z`)
|
||||||
: 'Best day out';
|
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||||
|
|
||||||
// The end row is the last comfortable hour, so the window runs to the end
|
// 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.
|
// of it - 9am-12pm means 9:00 up to 12:59.
|
||||||
const endIso = `${best.to.iso.slice(0, 11)}${String((parseInt(best.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;
|
const endIso = `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;
|
||||||
|
|
||||||
// A run spanning nearly all the daylight is better said than shown: printing
|
// 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".
|
// "6am - 9pm" makes the reader parse a time range to learn "all day".
|
||||||
const window = best.daylight > 0 && best.len >= best.daylight - 1
|
const window = c.daylight > 0 && c.len >= c.daylight - 1
|
||||||
? 'comfortable all day'
|
? 'comfortable all day'
|
||||||
: `${hh(best.from.iso)} – ${hh(endIso)}`;
|
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
||||||
|
|
||||||
return [{
|
return {
|
||||||
icon: '📅',
|
icon: '📅',
|
||||||
label,
|
label: dayName,
|
||||||
value: `${dayName} · ${window}`,
|
value: window,
|
||||||
alert: false,
|
// Rows stay in date order - the order you plan in - so the ranking is
|
||||||
grp: 'felt',
|
// carried by the score bar instead of by position.
|
||||||
}];
|
score: c.score,
|
||||||
|
alert: false,
|
||||||
|
// No grp: the group tints categorise the glance rail's mixed subjects,
|
||||||
|
// but here every row is the same kind of thing, so a colour would be
|
||||||
|
// decoration. Plain rows with hairline dividers instead.
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header line for the panel: names what is being judged when a variant makes
|
||||||
|
// that non-obvious, otherwise the plain framing.
|
||||||
|
export function bestDaysLabel(profile, variant) {
|
||||||
|
return profile === 'outdoors' && variant
|
||||||
|
? `Best times for ${deriveProfileMain(profile, variant).mainLabel}`
|
||||||
|
: 'Best times out';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeCropAdvice(weekDays, lat) {
|
export function computeCropAdvice(weekDays, lat) {
|
||||||
|
|||||||
+9
-5
@@ -198,7 +198,9 @@ export function deriveProfileMain(activeProfile, outdoorsVariant) {
|
|||||||
const mainField = isHomeOrOffice ? 'indoorT' : isVehicleProfile ? 'vehicleT' : isPetsProfile ? 'furSurfaceT' : 'utciAdj';
|
const mainField = isHomeOrOffice ? 'indoorT' : isVehicleProfile ? 'vehicleT' : isPetsProfile ? 'furSurfaceT' : 'utciAdj';
|
||||||
// Pets pairs Fur Temp with PET Shade: the human Shade column is now a UTCI
|
// Pets pairs Fur Temp with PET Shade: the human Shade column is now a UTCI
|
||||||
// felt number on a human comfort scale, which says nothing about a cat.
|
// felt number on a human comfort scale, which says nothing about a cat.
|
||||||
const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'petShadeT' : isVehicleProfile ? null : 'shadeT';
|
// Vehicle/Driver pairs the cabin temp with plain air temperature, so the
|
||||||
|
// gap between outside and inside the vehicle is visible at a glance.
|
||||||
|
const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'petShadeT' : isVehicleProfile ? 'Ta' : 'shadeT';
|
||||||
const mainLabel = activeProfile === 'outdoors'
|
const mainLabel = activeProfile === 'outdoors'
|
||||||
? (OUTDOORS_VARIANTS[outdoorsVariant]?.name || FILTER_PROFILES.outdoors.label)
|
? (OUTDOORS_VARIANTS[outdoorsVariant]?.name || FILTER_PROFILES.outdoors.label)
|
||||||
: (FILTER_PROFILES[activeProfile]?.label || 'SunSoak');
|
: (FILTER_PROFILES[activeProfile]?.label || 'SunSoak');
|
||||||
@@ -209,16 +211,18 @@ export function deriveProfileMain(activeProfile, outdoorsVariant) {
|
|||||||
// every field matches its column key except utciAdj, whose column is utciP.
|
// every field matches its column key except utciAdj, whose column is utciP.
|
||||||
const colKey = mainField === 'utciAdj' ? 'utciP' : mainField;
|
const colKey = mainField === 'utciAdj' ? 'utciP' : mainField;
|
||||||
const mainMetric = COL_DESCRIPTIONS[colKey] || {};
|
const mainMetric = COL_DESCRIPTIONS[colKey] || {};
|
||||||
// Vehicle/Driver has no secondary column — the day tab shows only the cabin
|
// Same mapping for the secondary field: Ta's column key is `air`.
|
||||||
// temp for the config actually selected (vehicle type, ventilation, speed).
|
const secColKey = secondaryField === 'Ta' ? 'air' : secondaryField;
|
||||||
const secondaryMetric = secondaryField ? (COL_DESCRIPTIONS[secondaryField] || {}) : null;
|
const secondaryMetric = secColKey ? (COL_DESCRIPTIONS[secColKey] || {}) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isHomeOrOffice, isVehicleProfile, isPetsProfile,
|
isHomeOrOffice, isVehicleProfile, isPetsProfile,
|
||||||
mainConfigKey, mainField, secondaryField, mainLabel,
|
mainConfigKey, mainField, secondaryField, mainLabel,
|
||||||
mainMetricLabel: mainMetric.title || '',
|
mainMetricLabel: mainMetric.title || '',
|
||||||
mainMetricDesc: mainMetric.desc || '',
|
mainMetricDesc: mainMetric.desc || '',
|
||||||
secondaryMetricLabel: secondaryMetric?.title || '',
|
// "Air Temperature" is too wide for the day-tab legend column, so the
|
||||||
|
// vehicle pairing shows it as just "Air" (the tooltip still spells it out).
|
||||||
|
secondaryMetricLabel: secColKey === 'air' ? 'Air' : (secondaryMetric?.title || ''),
|
||||||
secondaryMetricDesc: secondaryMetric?.desc || '',
|
secondaryMetricDesc: secondaryMetric?.desc || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,3 +516,34 @@ export function grassFillForElev(e) {
|
|||||||
if (e > -14) return { top: '#363356', bot: '#1c1a34' }; // nautical night
|
if (e > -14) return { top: '#363356', bot: '#1c1a34' }; // nautical night
|
||||||
return { top: '#201d3a', bot: '#0d0b20' }; // astronomical night
|
return { top: '#201d3a', bot: '#0d0b20' }; // astronomical night
|
||||||
}
|
}
|
||||||
|
// --- scoreFillColor --------------------------------------------------------
|
||||||
|
// Colour for the "Best Days Out" score bar, so the hue carries the same
|
||||||
|
// message as the length: red at 0, amber through the middle, green at 100.
|
||||||
|
//
|
||||||
|
// Straight hue interpolation across the red-yellow-green arc (0 deg to 120)
|
||||||
|
// rather than blending between three fixed hex stops - the blend route in RGB
|
||||||
|
// dips through brown around the midpoint, which reads as a fault rather than
|
||||||
|
// a middling day. Saturation and lightness stay put so the bar keeps one
|
||||||
|
// weight across the range instead of the yellow end glaring.
|
||||||
|
export function scoreFillColor(score) {
|
||||||
|
const pct = Math.max(0, Math.min(100, score ?? 0));
|
||||||
|
return `hsl(${Math.round(pct * 1.2)} 68% 44%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- titleCaseText ---------------------------------------------------------
|
||||||
|
// Insight-panel labels and values were written ad hoc over time, so the rail
|
||||||
|
// mixed "Peak felt temp" with "Air quality" with "+9.0° warmer". Rather than
|
||||||
|
// hand-editing every string (many are built at runtime, and app.js matches
|
||||||
|
// some of them by exact text when ordering rows), the panels title-case at
|
||||||
|
// render time.
|
||||||
|
//
|
||||||
|
// Only capitalises a letter that STARTS a word - one preceded by nothing,
|
||||||
|
// whitespace or punctuation. A letter run glued to a number is a unit, not a
|
||||||
|
// word, so "6am" and "10°C" survive as written rather than becoming "6Am".
|
||||||
|
// Letters already capitalised are never touched, which keeps UV and AQI whole.
|
||||||
|
export function titleCaseText(s) {
|
||||||
|
return String(s ?? '').replace(
|
||||||
|
/(^|[^A-Za-z0-9°'])([a-z])/g,
|
||||||
|
(_, before, ch) => before + ch.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user