3.9
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:
+118
-9
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user