Summaries added — every column in COL_DESCRIPTIONS got a short (1–2 sentences) and a link to its reference row. Full desc text kept intact for the day-tab metric labels.
Popup rewired — renders the summary plus a "More about this column →" link, opening in a new tab.
Link styling — .col-info-more pinned below the summary so it stays visible if the text scrolls; centred on mobile with the rest.
Anchors on the reference page — 35 row ids in columns.html, one per column key.
New "Rows" row — the hour/interval header had a popup but no matching row on the reference page; added.
Landing highlight — tr:target gets a gold bar and tint, with scroll offset so the nav doesn't cover it.
This commit is contained in:
Fraxle
2026-08-03 18:06:09 +01:00
parent 2035edad23
commit 8f161dc8cb
18 changed files with 668 additions and 123 deletions
+118 -9
View File
@@ -18,7 +18,7 @@ import {
calcFurSurfaceTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS } from './utils.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR } from './config.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open;
@@ -723,10 +723,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
grp: 'felt',
}] : []),
...lightningItem,
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
}] : []),
...(show('pollen') && maxPollen >= 10 ? [{
@@ -778,10 +778,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
grp: 'felt',
}] : []),
...lightningItem,
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
grp: 'airqual',
}] : []),
@@ -825,10 +825,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
alert: !!(peakUvRow && peakUvRow.uv >= 6),
grp: 'solar',
}] : []),
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
}] : []),
...(show('pollen') && maxPollen >= 10 ? [{
@@ -878,10 +878,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
}] : []),
...(show('precipProb') ? [rainItem] : []),
...lightningItem,
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
grp: 'airqual',
}] : []),
@@ -958,6 +958,115 @@ function bestRun(arr, pred) {
return best;
}
// ------------------------------------------------------------------------
// computeBestDay(weekDays, profile, variant, nowLocalISO) - the day with the
// longest unbroken run of pleasant outdoor hours, for "The Week Ahead".
//
// The app already works out comfort windows for the selected day, but
// comparing days meant tapping through all fourteen tabs. This answers the
// question the day tabs make you do by hand.
//
// Deliberately NOT part of computeGlanceSummary: "At a glance" describes the
// 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.
//
// Scored on utciAdj (SunSoak) rather than the profile's own main field.
// Every row has it whatever profile is active, and it is the number that
// 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
// morning that has been and gone can't win.
// ------------------------------------------------------------------------
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
function comfortableHour(r) {
return r.elev > 0 // daylight
&& r.utciAdj != null && r.utciAdj >= 9 && r.utciAdj <= 28 // no thermal stress
&& (r.precipProb ?? 0) < 50 // unlikely to rain on you
&& (r.gust ?? r.va ?? 0) * 2.237 < 32; // below near-gale
}
export function computeBestDay(weekDays, profile, variant, nowLocalISO) {
if (!weekDays || weekDays.length === 0) return [];
const candidates = [];
for (const day of weekDays.slice(0, 7)) {
if (!day?.rows?.length) continue;
// Drop hours that have already passed - only ever bites on day 0.
const rows = nowLocalISO
? day.rows.filter(r => r.iso.slice(0, 13) >= nowLocalISO)
: day.rows;
let start = -1, len = 0, dayBest = null;
for (let i = 0; i < rows.length; i++) {
if (comfortableHour(rows[i])) {
if (start < 0) start = i;
len++;
if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
} else {
start = -1; len = 0;
}
}
if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;
// How far the run sits from an ideal ~20 °C, averaged. Length alone can't
// separate days: in a temperate summer week half of them run comfortable
// from dawn to dusk, and picking arbitrarily among those is a coin toss.
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;
candidates.push({
len: dayBest.len, miss, key: day.key,
from: rows[dayBest.start], to: rows[dayBest.end],
daylight: rows.filter(r => r.elev > 0).length,
});
}
if (candidates.length === 0) return [];
// Longest run wins, but anything within an hour of the longest counts as a
// tie and is settled on which day is actually the most pleasant.
const maxLen = Math.max(...candidates.map(c => c.len));
const best = candidates
.filter(c => c.len >= maxLen - 1)
.sort((a, b) => a.miss - b.miss)[0];
const hh = (iso) => {
const h = parseInt(iso.slice(11, 13), 10);
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
// is being judged rather than repeating the timeframe.
const label = profile === 'outdoors' && variant
? `Best for ${deriveProfileMain(profile, variant).mainLabel}`
: 'Best day out';
// 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 = `${best.to.iso.slice(0, 11)}${String((parseInt(best.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
// "6am - 9pm" makes the reader parse a time range to learn "all day".
const window = best.daylight > 0 && best.len >= best.daylight - 1
? 'comfortable all day'
: `${hh(best.from.iso)} ${hh(endIso)}`;
return [{
icon: '📅',
label,
value: `${dayName} · ${window}`,
alert: false,
grp: 'felt',
}];
}
export function computeCropAdvice(weekDays, lat) {
if (!weekDays || weekDays.length === 0) return [];
const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length);