New big and small scope sunset and sunrise simulation
New calculations for the day tabs
Added missing icons to simple view
New record popups
Week ahead description popups
Camping profile
Tweak to some of the indoor tems
This commit is contained in:
fraxle
2026-10-08 09:53:41 +01:00
parent 71860b9dd9
commit 862f631beb
22 changed files with 2169 additions and 429 deletions
+202 -28
View File
@@ -47,6 +47,21 @@ import { exportDayXls } from './export.js';
const html = htm.bind(h);
// ── Quick Record hand-off ───────────────────────────────────────────────
// The event banner's "Record" button doesn't save anything itself — the
// journal lives behind the dashboard's own session (api/journal-save.php),
// which this page has no form for. It parks the entry it wants in
// localStorage and sends you to the dashboard, which picks it up on the
// first sign-in-complete load and opens its wizard on the review page,
// filled in. See "Quick Record hand-off" in dashboard.html for the other
// half, and keep the key and shape below in step with it.
const QUICK_RECORD_KEY = 'sunscope_journal_prefill';
// The journal can only compute a reading for the four profiles its own
// API accepts (see api/journal-save.php). Anything else — Farming, an
// Outdoors variant, Custom — hands over no profile at all, and the
// dashboard keeps whichever one it already had.
const QUICK_RECORD_PROFILES = new Set(['basic', 'home', 'vehicle', 'pets']);
// ── Scope time-lapse playback ───────────────────────────────────────────
// Drives the big scope through the next 24h so you can watch the sky/ground
// gradients and weather evolve. Speed: 15 simulated minutes every 1 real
@@ -246,6 +261,7 @@ export function UTCIForecast() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
hourlyRows, days, utcOffsetMs,
visible, tableRows, nowLocalISO, currentRow, currentCat,
@@ -397,6 +413,45 @@ export function UTCIForecast() {
};
}, [activeEvents.length]);
// ── Quick Record ─────────────────────────────────────────────────────
// Sends an event to the journal, along with the location, profile and
// solar model it was read under, so the reading the dashboard works out
// is the same one this page is showing. Three ways in: the banner, which
// files under today; an event tag in the table, which files under the day
// that row belongs to — the table can be showing any of the 14, and a
// meteor shower tagged on Thursday is a Thursday entry; and a "What's
// Coming" card, which files under that event's peak date. A peak years out
// is fine — the dashboard finds no forecast for it and saves the entry
// note-only, which is exactly what planning a night out under the Perseids
// wants.
//
// Today here is today in the *location's* local time, not the browser's —
// a stargazing note for a place eight hours away belongs on that place's
// date. See QUICK_RECORD_KEY at the top of this file.
const quickRecord = (ev, dateISO) => {
if (!ev) return;
const today = (nowLocalISO || new Date().toISOString()).slice(0, 10);
const blurb = ev.message || ev.desc || '';
const payload = {
savedAt: Date.now(),
eventId: ev.id,
title: ev.title,
date: /^\d{4}-\d{2}-\d{2}$/.test(dateISO || '') ? dateISO : today,
// Title and message both, as one line: the entries list renders a note
// as a single run of text, so a wrapped-in paragraph adds nothing.
// Banner and tag events call their copy `message`; the almanac entries
// behind "What's Coming" call theirs `desc` — either way it's the one
// sentence describing the event.
note: `${ev.emoji} ${ev.title}${blurb ? ` — ${blurb}` : ''}`,
location: { name: location.name, lat: location.lat, lon: location.lon },
profile: QUICK_RECORD_PROFILES.has(activeProfile) ? activeProfile : null,
solarEnv: utciEnv,
};
try { localStorage.setItem(QUICK_RECORD_KEY, JSON.stringify(payload)); } catch (e) { /* ignore, e.g. private mode */ }
// `location` is the forecast location in this scope, not the browser's.
window.location.href = './dashboard.html';
};
const [colTogglesOpen, setColTogglesOpen] = useState(false);
// Which column pills are offered in the Columns bar. The bar as a whole is
// Extra-only (see the isPro gate around col-toggles-body), so this just asks
@@ -493,6 +548,14 @@ export function UTCIForecast() {
}
}, [activeProfile, outdoorsVariant]);
// Keep Quick view's Indoors selection in step with the table: follow the
// Managed toggle, and fall back to SunSoak if Indoors is switched off.
useEffect(() => {
if (simpleTemp !== 'indoorT' && simpleTemp !== 'managedT') return;
if (indoorMode !== 'on') setSimpleTemp('utciAdj');
else setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
}, [indoorMode, indoorManaged, simpleTemp]);
// Focus the input the moment the search opens.
useEffect(() => {
if (searchOpen) searchInputRef.current?.focus();
@@ -583,7 +646,7 @@ export function UTCIForecast() {
// columns are skipped entirely. Hour always gets a blank lead cell.
const groupLabelRow = () => {
const groups = Object.keys(GROUP_ORDER);
const cells = [html`<th class="grp-label-hour" scope="col"><span class="grp-label-date">${glanceDate}</span></th>`];
const cells = [html`<th class="grp-label-hour" scope="col"><span class="grp-label-date">${glanceDateShort}</span></th>`];
for (const g of groups) {
const span = GROUP_ORDER[g].filter(isColVisible).length;
if (span === 0) continue;
@@ -956,19 +1019,39 @@ export function UTCIForecast() {
onClick=${() => noteGoTo(i)}
/>`)}
</div>`}
<button
type="button"
class="event-note-close"
aria-label="Close event banner"
title="Close"
onClick=${noteClose}
>
<span class="event-note-close-label">Close</span>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M1.5 1.5 L10.5 10.5 M10.5 1.5 L1.5 10.5"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
${/* Close sits above Quick Record so the two stack in the strip's
top-right corner rather than running side by side. Quick Record
acts on whatever slide is showing — it sits outside the stack
so the strip keeps one button rather than one per event. */''}
<div class="event-note-actions">
<button
type="button"
class="event-note-close"
aria-label="Close event banner"
title="Close"
onClick=${noteClose}
>
<span class="event-note-close-label">Close</span>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M1.5 1.5 L10.5 10.5 M10.5 1.5 L1.5 10.5"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class="event-note-record"
aria-label=${`Record “${ev.title}” in your journal`}
title="Add this to your weather journal"
onClick=${() => quickRecord(ev)}
>
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2.4" />
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
</svg>
<span class="event-note-record-label">Quick Record</span>
</button>
</div>
</div>
</div>`;
})()}
@@ -1167,6 +1250,7 @@ export function UTCIForecast() {
wind=${scopeRow?.va ?? 0}
gust=${scopeRow?.gust ?? 0}
wd=${scopeRow?.wd ?? null}
rh=${scopeRow?.RH ?? null}
/>
${hourlyRows.length > 0 && html`
<div class="scope-daycycle-wrap">
@@ -1381,7 +1465,9 @@ export function UTCIForecast() {
const showSolar = visibleCols.utciP;
const showShade = visibleCols.shadeT;
const showVehicle = visibleCols.vehicleT;
const showIndoor = visibleCols.indoorT || visibleCols.managedT;
// Same rule as the table's Indoors/Managed columns (indoorMode),
// so the tab and the column always appear together.
const showIndoor = indoorMode === 'on';
if (!showFur && !showSolar && !showShade && !showVehicle && !showIndoor) return null;
const furOn = simpleTemp === 'furSurfaceT';
const solarOn = simpleTemp === 'utciAdj';
@@ -1506,7 +1592,8 @@ export function UTCIForecast() {
return null;
})();
const windMph = Math.round((r.gust ?? r.va) * 2.237);
// Tag shade = the exact gradient stop the curve is painted
// Same event emojis the table's hour cell shows, for this card's hours.
const cardEvents = getCellTagEvents(selectedDayEvents, r); // Tag shade = the exact gradient stop the curve is painted
// with at this card's landing hour (fsc-grad, whiteMix 0.42),
// so the connector line lands on the colour the tag is
// wearing. Band label and weight still come from the band.
@@ -1532,9 +1619,20 @@ export function UTCIForecast() {
}}>
<div class="fsc-time">${localHHMMs}</div>
<div class="fsc-scope-wrap">
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} />
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42}
cc=${r.cc} ccLow=${r.ccLow} ccMid=${r.ccMid} ccHigh=${r.ccHigh} visKm=${r.visKm} rh=${r.RH} />
${domIcon && html`<div class="fsc-wx-overlay">${domIcon}</div>`}
</div>
<div class="fsc-ev-row">
${cardEvents.map((ev, idx) => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(cardEvents, idx, r.iso.slice(0, 10), e)}
onMouseEnter=${(e) => handleEventTagEnter(cardEvents, idx, r.iso.slice(0, 10), e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
`)}
</div>
<div class="fsc-meta">
<span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph${r.compass ? html` <span class="fsc-meta-dir">${r.compass.label}</span>` : ''}</span>
@@ -1656,14 +1754,15 @@ export function UTCIForecast() {
const rowEvents = getCellTagEvents(selectedDayEvents, r);
return html`
<span class="utci-time-inner">
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38}
cc=${r.cc} ccLow=${r.ccLow} ccMid=${r.ccMid} ccHigh=${r.ccHigh} visKm=${r.visKm} rh=${r.RH} />
<span>${localHHMM}</span>
${rowEvents.length > 0 && html`
<span class="utci-time-tags">
${rowEvents.map((ev, idx) => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
onClick=${(e) => handleEventTagClick(rowEvents, idx, r.iso.slice(0, 10), e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, r.iso.slice(0, 10), e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
@@ -1874,19 +1973,39 @@ export function UTCIForecast() {
<div class="insight-panel insight-panel--week">
<div class="insight-panel-title">The Week Ahead</div>
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
${weekAhead.map(({ icon, iconTitle, label, value, score }) => html`
<div class=${`insight-row${score == null ? ' insight-row--week-note' : ''}`} key=${label}>
<span class="insight-icon" title=${iconTitle}>${icon}</span>
${weekAhead.map(({ icon, iconTitle, label, value, score }) => {
const clickable = score != null;
const popupPayload = clickable ? {
key: label,
title: titleCaseText(label),
sub: titleCaseText(value),
rows: [
...(iconTitle ? [{ icon, text: iconTitle }] : []),
{ icon: '📊', text: bestDaysHint(activeProfile, outdoorsVariant, score) },
],
} : null;
return html`
<div
class=${`insight-row${score == null ? ' insight-row--week-note' : ''}${clickable ? ' insight-row--clickable' : ''}${weekPopup?.key === label ? ' is-open' : ''}`}
key=${label}
role=${clickable ? 'button' : null}
tabIndex=${clickable ? 0 : null}
aria-expanded=${clickable ? (weekPopup?.key === label ? 'true' : 'false') : null}
onClick=${clickable ? (e) => handleWeekRowClick(popupPayload, e) : null}
onKeyDown=${clickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleWeekRowClick(popupPayload, e); } } : null}
>
<span class="insight-icon">${icon}</span>
<span class="insight-label">${titleCaseText(label)}</span>
<span class="insight-value">${titleCaseText(value)}</span>
${score != null && html`
<span class="week-score" title=${bestDaysHint(activeProfile, outdoorsVariant, score)}>
<span class="week-score">
<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>`}
${visible.length > 0 && html`
<div class="export-panel">
@@ -1954,6 +2073,23 @@ export function UTCIForecast() {
<strong class="col-info-title">${ev.emoji} ${ev.title}</strong>
<p class="col-info-desc">${ev.message}</p>
</div>
${/* Files under the day the tag sits on, not today — see
quickRecord. Outside the stage, so the crossfade
between events doesn't take the button with it. */''}
<button
type="button"
class="ev-popup-record"
aria-label=${`Record “${ev.title}” in your journal`}
title="Add this to your weather journal"
onClick=${() => quickRecord(ev, eventTagPopup.date)}
>
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2.4" />
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
</svg>
Quick Record
</button>
${evs.length > 1 && html`
<div class="ev-popup-dots">
${evs.map((_, i) => html`
@@ -1968,6 +2104,27 @@ export function UTCIForecast() {
</div>`;
})()}
${weekPopup && html`
<div
ref=${weekPopupRef}
class=${`col-info-popup col-info-popup--week${weekPopup.below ? ' col-info-popup--below' : ''}`}
style=${{
position: 'fixed',
left: `${weekPopup.x}px`,
top: `${weekPopup.y}px`,
transform: weekPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
'--arrow-left': `${weekPopup.arrowLeft}px`,
}}
>
<button class="col-info-close" onClick=${closeWeekPopup} aria-label="Close">×</button>
<strong class="col-info-title">${weekPopup.title}</strong>
${weekPopup.sub && html`<p class="col-info-desc col-info-desc--lead">${weekPopup.sub}</p>`}
<ul class="week-popup-list">
${weekPopup.rows.map((r, i) => html`
<li key=${i}><span class="week-popup-icon" aria-hidden="true">${r.icon}</span>${r.text}</li>`)}
</ul>
</div>`}
<div class="utci-legend">
<span class="utci-legend-label">Thermal stress bands</span>
<div class="utci-legend-row">
@@ -2065,9 +2222,26 @@ export function UTCIForecast() {
<span class="almanac-entry-countdown">${countdownLabel(ev.daysUntil)}</span>
</div>
<div class="almanac-entry-desc">${ev.desc}</div>
${ev.visibilityNote && html`
<span class="almanac-entry-visibility">📍 ${ev.visibilityNote}</span>
`}
${/* Files under the event's peak, not today — see quickRecord. */''}
<div class="almanac-entry-foot">
${ev.visibilityNote && html`
<span class="almanac-entry-visibility">📍 ${ev.visibilityNote}</span>
`}
<button
type="button"
class="almanac-entry-record"
aria-label=${`Record “${ev.title}” in your journal`}
title="Add this to your weather journal"
onClick=${() => quickRecord(ev, ev.peak)}
>
<svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2.4" />
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
</svg>
Quick Record
</button>
</div>
</div>
</div>
`)
+116 -40
View File
@@ -21,7 +21,7 @@ import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-h
import htm from '../vendor/htm.js';
import {
skyGradientForElev, skyFillForElev, grassFillForElev,
moonPhaseFraction,
moonPhaseFraction, sunLightState,
} from './utils.js';
import { getLensOverlaySVG } from './events.js';
@@ -38,6 +38,17 @@ function blendHex(a, b, t) {
const A = _hexRgb(a), B = _hexRgb(b);
return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join('');
}
// Sun centre Y for a scope. The disk sits fully above the horizon at
// +0.27 deg, is half-set at -0.28 deg and fully gone at -0.83 deg (standard
// sunset incl. refraction), then keeps sinking so its clipped halo still
// lights the sky above the horizon through twilight.
// horizonY - y of the horizon line, sunR - disk radius,
// span - extra travel from horizon-resting to the zenith
function sunCentreY(elev, horizonY, sunR, span) {
const rim = Math.max(-1, Math.min(1, (elev + 0.28) / 0.55));
const arc = Math.sin(Math.max(-12, Math.min(90, elev)) * Math.PI / 180);
return horizonY - sunR * rim - span * arc;
}
// Tiny seeded PRNG (mulberry32). Deterministic so the scattered ground
// objects keep the same positions on every re-render (no jitter).
function seededRng(seed) {
@@ -265,8 +276,7 @@ export function VentPill({ checked, onChange, label, title, grpClass }) {
// value picks the clock-aligned bucket size used by aggregateRows()
// (1h = every hour, 4h = quarter-day blocks).
//
// "-" steps toward 1h (finer, more rows), "+" toward 4h (coarser).
// Direction follows the readout, so + always raises the number shown.
// Zoom-style: "+" zooms in toward 1h (finer, more rows), "-" zooms out toward 4h (coarser).
//
// props:
// value - 1..4
@@ -283,14 +293,14 @@ export function RowStepper({ value, onChange, showLabel = true, label = 'Rows',
return html`
<span class="row-stepper" role="group" aria-label="Row interval">
${showLabel && html`<span class="row-stepper-label">${label}</span>`}
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}>−</button>
<span class="row-stepper-value" aria-live="polite"
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
<button type="button" class="row-stepper-btn" disabled=${v >= max}
title="Coarser rows" aria-label="Coarser rows - more hours per row"
onClick=${() => step(v + 1)}>+</button>
onClick=${() => step(v + 1)}>−</button>
<span class="row-stepper-value" aria-live="polite"
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}>+</button>
</span>`;
}
@@ -311,7 +321,8 @@ export function RowStepper({ value, onChange, showLabel = true, label = 'Rows',
// - sunR - the sun's drawn radius
// - the stroke colour #c8922a is the brass - change for a different metal
// -------------------------------------------------------------------
export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
export function SkyScope({ elev, dt, glob = 0, size = 26,
cc = null, ccLow = null, ccMid = null, ccHigh = null, visKm = null, rh = null }) {
const r = size / 2;
const innerR = r - 1.2;
@@ -319,23 +330,28 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const localHour = dt ? dt.getUTCHours() : 12;
const isRising = localHour < 12;
const skyGrad = skyGradientForElev(elev, isRising);
// Sun colour / glow / afterglow from elevation + atmosphere (see utils.js)
const light = sunLightState({ elev, isRising, cc, ccLow, ccMid, ccHigh, visKm, rh });
const skyGradBase = skyGradientForElev(elev, isRising);
// Horizon end of the sky picks up the glow colour so sky and sun agree
const skyGrad = { top: skyGradBase.top, bot: blendHex(skyGradBase.bot, light.glow, light.skyGlow * 0.35) };
const isDay = elev > 0; // sun only drawn when actually above horizon
const isDay = elev > 0;
const isTwil = elev > -8 && !isDay; // moon shown in twilight too
const isNight = elev <= -8;
const showSun = elev > -6; // halo still lights the sky just after sunset
// Sun position - clamped so it never goes below the horizon line (y = r)
const elevClamped = Math.max(0, Math.min(90, elev));
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
const sunR = Math.max(2.2, innerR * 0.32);
// Sun position - sinks behind the horizon (y = r) instead of overlapping the land
const sunR = Math.max(1.8, innerR * 0.24); // smaller disk; glow carries the strength
const sunY = sunCentreY(elev, r, sunR, innerR - 2.5 - sunR);
// Radial glow: strength driven by glob (W/m-). Uses a radialGradient
// so it's intense at the sun centre and fades smoothly to transparent.
const radFrac = Math.min(1, (glob || 0) / 900); // 0 (dark) - 1 (blazing)
const horizFrac = Math.max(0, 1 - elev / 20); // extra warmth near horizon
const glowR = sunR + 4 + radFrac * 6; // total glow radius
const glowPeak = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
const glowR = (sunR + 5 + radFrac * 10) * (1 + light.haze * 0.3); // total glow radius (haze spreads it)
const glowPeak = Math.min(1, 0.35 + radFrac * 0.45 + horizFrac * 0.15); // centre opacity
const haloFade = Math.max(0, Math.min(1, (elev + 6) / 6.3)); // halo dims as the sun sinks below
const phase = moonPhaseFraction(dt);
const moonR = innerR * 0.55;
@@ -343,8 +359,15 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const shadowCx = r + (phase < 0.5 ? -shadowOffset : shadowOffset);
const grass = grassFillForElev(elev);
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}`;
// uid must include every input that changes a gradient, or cells sharing an
// elevation would reuse each other's sun colours
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}` +
`-${light.glow.slice(1)}-${light.core.slice(1)}-${Math.round(light.skyGlow * 50)}` +
`-${Math.round(light.groundGlow * 50)}-${Math.round(light.diskAlpha * 20)}-${Math.round(radFrac * 50)}` +
`-${Math.round(light.haze * 20)}`;
const clipId = `clip-${uid}`;
const skyHalfId = `skyhalf-${uid}`;
const groundHalfId = `groundhalf-${uid}`;
const skyId = `sky-${uid}`;
const grassId = `grass-${uid}`;
@@ -355,6 +378,19 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<clipPath id=${clipId}>
<circle cx=${r} cy=${r} r=${innerR} />
</clipPath>
<!-- Sky / ground halves — the sun and its glow are cut at the horizon -->
<clipPath id=${skyHalfId}><rect x="0" y="0" width=${size} height=${r} /></clipPath>
<clipPath id=${groundHalfId}><rect x="0" y=${r} width=${size} height=${r} /></clipPath>
<!-- Horizon afterglow (sky) and warm wash (ground), stretched by the ellipse -->
<radialGradient id=${`skyglow-${uid}`}>
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.skyGlow} />
<stop offset="55%" stop-color=${light.glow} stop-opacity=${light.skyGlow * 0.4} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<radialGradient id=${`groundglow-${uid}`}>
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.groundGlow} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Sky gradient — top of disk to bottom (zenith → horizon) -->
<linearGradient id=${skyId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${skyGrad.top} />
@@ -368,10 +404,10 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
<radialGradient id=${`glow-${uid}`} gradientUnits="userSpaceOnUse"
cx=${r} cy=${sunY} r=${glowR}>
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
<stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
<stop offset="70%" stop-color="#ffe060" stop-opacity=${glowPeak * 0.5} />
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
<stop offset="0%" stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset="70%" stop-color=${light.sun} stop-opacity=${glowPeak * 0.5} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: lit area = white, shadow = black punched out -->
<mask id=${`moon-mask-${uid}`}>
@@ -388,15 +424,30 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<!-- Sky disk -->
<circle cx=${r} cy=${r} r=${innerR} fill=${`url(#${skyId})`} />
<!-- Horizon afterglow in the sky -->
${light.skyGlow > 0.01 && html`
<g clip-path=${`url(#${clipId})`}>
<ellipse cx=${r} cy=${r} rx=${innerR} ry=${innerR * (0.55 + 0.25 * light.haze)}
fill=${`url(#skyglow-${uid})`} clip-path=${`url(#${skyHalfId})`} />
</g>`}
<!-- Ground half (lower semicircle) -->
<path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
fill=${`url(#${grassId})`}
clip-path=${`url(#${clipId})`} />
${isDay && html`
<!-- Warm low-sun wash on the ground -->
${light.groundGlow > 0.01 && html`
<g clip-path=${`url(#${clipId})`}>
<!-- Glow + sun disk as one seamless radial gradient circle -->
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`} />
<ellipse cx=${r} cy=${r} rx=${innerR * 0.9} ry=${innerR * 0.35}
fill=${`url(#groundglow-${uid})`} clip-path=${`url(#${groundHalfId})`} />
</g>`}
${showSun && html`
<g clip-path=${`url(#${clipId})`}>
<!-- Glow + sun disk as one seamless radial gradient circle, cut at the horizon -->
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`}
opacity=${haloFade} clip-path=${`url(#${skyHalfId})`} />
</g>`}
${(isTwil || isNight) && html`
@@ -593,7 +644,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
// -------------------------------------------------------------------
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0, activeEvent = null, utciEnvShort = null,
cc = null, ccLow = null, ccMid = null, ccHigh = null, precip = 0, snow = 0, visKm = null,
env = 'open', wind = 0, gust = 0, wd = null }) {
env = 'open', wind = 0, gust = 0, wd = null, rh = null }) {
// -- Day/night scene behind the readout -----------------------------
// The whole back of the lens is a SkyScope-style scene: sky on top,
// grass on the bottom, sun positioned by elevation (or moon at night
@@ -630,8 +681,11 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
let tintAmt = cloudF * 0.72;
if (wet) tintAmt = Math.min(0.88, tintAmt + 0.14);
if (heavy) tintAmt = Math.min(0.93, tintAmt + 0.12);
// Sun colour / glow / afterglow from elevation + atmosphere (see utils.js).
// The horizon end of the sky picks up the glow colour before weather grey.
const light = sunLightState({ elev, isRising, cc, ccLow, ccMid, ccHigh, visKm, rh });
const skyTopTint = blendHex(skyGrad.top, isDay ? greyTarget : blendHex(greyTarget, '#0e1018', 0.4), tintAmt * 0.8);
const skyBotTint = blendHex(skyGrad.bot, greyTarget, tintAmt);
const skyBotTint = blendHex(blendHex(skyGrad.bot, light.glow, light.skyGlow * 0.35), greyTarget, tintAmt);
// Drifting cloud sprites, one strip per coverage layer. Each strip holds a
// few sprites duplicated +200px so the CSS drift wraps seamlessly.
@@ -910,16 +964,18 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
const phase = moonPhaseFraction(dt);
const lensR = 95;
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
const sunDrawR = 14;
const sunDrawR = 11; // smaller disk; glow carries the strength
// Sun sinks behind the horizon (y = cy) instead of overlapping the land
const sunY = sunCentreY(elev, cy, sunDrawR, lensR - 18 - sunDrawR);
const showSun = elev > -6; // halo still lights the sky just after sunset
// Radial glow for the big dial - sun is always at least faintly glowing
// when above the horizon; scales up with radiation strength.
const radFrac = Math.min(1, (glob || 0) / 900);
const horizFrac = Math.max(0, 1 - elev / 20);
const sunGlowCol = isRising ? '#ffb040' : '#ffc060';
const sunGlowR = sunDrawR + 20 + radFrac * 30; // total glow radius
const sunGlowPeak = 0.30 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
const sunGlowR = (sunDrawR + 26 + radFrac * 44) * (1 + light.haze * 0.3); // total glow radius (haze spreads it)
const sunGlowPeak = Math.min(1, 0.30 + radFrac * 0.45 + horizFrac * 0.15); // centre opacity
const haloFade = Math.max(0, Math.min(1, (elev + 6) / 6.3)); // halo dims as the sun sinks below
const moonDrawR = 15;
const moonY = cy - 45;
const shadowOffset = (1 - Math.abs(2 * phase - 1)) * 2 * moonDrawR;
@@ -1129,10 +1185,23 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
<radialGradient id="scope-sun-glow" gradientUnits="userSpaceOnUse"
cx=${cx} cy=${sunY} r=${sunGlowR}>
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
<stop offset=${`${Math.round(sunDrawR / sunGlowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
<stop offset="70%" stop-color="#ffe060" stop-opacity=${sunGlowPeak * 0.5} />
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
<stop offset="0%" stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset=${`${Math.round(sunDrawR / sunGlowR * 100)}%`} stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset="70%" stop-color=${light.sun} stop-opacity=${sunGlowPeak * 0.5} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Sky / ground halves — the sun and its glow are cut at the horizon -->
<clipPath id="scope-sky-half"><rect x="0" y="0" width="200" height=${cy} /></clipPath>
<clipPath id="scope-ground-half"><rect x="0" y=${cy} width="200" height=${200 - cy} /></clipPath>
<!-- Horizon afterglow (sky) and warm wash (ground), stretched by the ellipse -->
<radialGradient id="scope-sky-afterglow">
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.skyGlow} />
<stop offset="55%" stop-color=${light.glow} stop-opacity=${light.skyGlow * 0.4} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<radialGradient id="scope-ground-glow">
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.groundGlow} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: white = lit area, black = shadow punched out -->
<mask id="scope-moon-mask">
@@ -1150,11 +1219,18 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
<!-- Full sky + grass + sun/moon scene behind the readout -->
<g clip-path="url(#scope-lens-clip)">
<circle cx=${cx} cy=${cy} r=${lensR} fill="url(#scope-lens-sky)" />
<!-- Horizon afterglow in the sky -->
<ellipse cx=${cx} cy=${cy} rx=${lensR} ry=${lensR * (0.55 + 0.25 * light.haze)}
fill="url(#scope-sky-afterglow)" clip-path="url(#scope-sky-half)" />
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
fill="url(#scope-lens-grass)" />
${isDay && html`<${Fragment}>
<!-- Glow + sun disk as one seamless radial gradient circle -->
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)" />
<!-- Warm low-sun wash on the ground -->
<ellipse cx=${cx} cy=${cy} rx=${lensR * 0.9} ry=${lensR * 0.35}
fill="url(#scope-ground-glow)" clip-path="url(#scope-ground-half)" />
${showSun && html`<${Fragment}>
<!-- Glow + sun disk as one seamless radial gradient circle, cut at the horizon -->
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)"
opacity=${haloFade} clip-path="url(#scope-sky-half)" />
</>`}
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
<!-- Lit crescent — full moon disk clipped by the phase mask -->
-6
View File
@@ -323,8 +323,6 @@ export function ConfigPanel({
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
@@ -344,8 +342,6 @@ export function ConfigPanel({
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
@@ -366,8 +362,6 @@ export function ConfigPanel({
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
else { setIndoorMode('off'); setIndoorManaged(false); }
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
+42 -9
View File
@@ -176,7 +176,28 @@ function cloudDim(rgb, w) {
// pale out the same way.
const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
const neutral = Math.min(255, lum + 14);
return mixRgb(rgb, [neutral, neutral, neutral], w);
// Channels BELOW the neutral are brought up faster than channels above it
// are brought down. This is the actual cure for the khaki, and the earlier
// fixes missed it: mixing all three channels at the same rate leaves gold's
// blue channel lagging far behind its red and green, and "red and green high,
// blue low" is the definition of khaki — so every partially clouded gold tab
// had to pass through it no matter how the darkening was tuned.
// Filling the deficient channel first routes gold -> cream -> grey instead,
// which never has all three channels spread apart. It also happens to be
// right at the cold end: for a pale blue the deficient channel is red, so
// blue is the one held back, and a clouded cold day pales toward grey while
// staying recognisably blue.
const fast = Math.sqrt(w);
const dulled = rgb.map(c => c + (neutral - c) * (c < neutral ? fast : w));
// Overcast is grey AND darker, but darkening can only be spent once the
// colour is actually neutral: darkening a HALF-desaturated yellow is exactly
// how olive is made, while darkening a true grey can only ever give a darker
// grey. So it is cubed against the same w that drives the desaturation —
// near-nothing through the middle where hue is still present (0.06 at w=0.5),
// arriving in force only once the colour has already gone neutral (0.31 at
// full cloud). That is what lets 100% cloud land on a genuinely darkish grey
// without putting khaki back on the warm bands.
return dulled.map(c => Math.round(c * (1 - 0.50 * w * w * w)));
}
// Weather weights are pushed through this before they are used.
@@ -258,16 +279,21 @@ export function DayTabs({
// The row pins to the top of the viewport on wide screens, and the table's
// sticky hour header has to stop just below it. Publish the measured height
// (plus its 4px margin) as --profile-row-h rather than hard-coding one in
// table.css: the row is one line or two depending on how long the profile
// and thermal-basis labels are, so a fixed offset leaves the header tucked
// under it.
// as --profile-row-h rather than hard-coding one in table.css: the row is
// one line or two depending on how long the profile and thermal-basis
// labels are, so a fixed offset leaves the header tucked under it.
// Nothing is added on top of that height. The row's 4px bottom margin is
// transparent and hangs below its border edge, so counting it only pushed
// the header 4px past the bottom of the band — a strip of the scrolling
// page showing through between the profile row and the column titles.
// Floored rather than rounded so a fractional height has the header
// overlap by a hair (the row outranks it on z-index) instead of gapping.
const profileRowRef = useRef(null);
useEffect(() => {
const el = profileRowRef.current;
if (!el) return;
const publish = () => {
const h = Math.round(el.getBoundingClientRect().height) + 4;
const h = Math.floor(el.getBoundingClientRect().height);
document.documentElement.style.setProperty('--profile-row-h', `${h}px`);
};
publish();
@@ -484,7 +510,9 @@ export function DayTabs({
// daylight repRows) since fog and frost often hit overnight or on
// an early commute, then picks the worst hazard by priority.
let vehicleHazard = null;
let hazPrecip = 0, hazSnow = 0;
let hazPrecip = 0, hazSnow = 0, hazDrops = 1;
const hazProbs = d.rows.map(r => r.precipProb).filter(v => isFinite(v));
const hazMaxProb = hazProbs.length ? Math.max(...hazProbs) : 0;
if (isVehicleProfile) {
const allSnow = d.rows.reduce((s, r) => s + (r.snow || 0), 0);
const allPrecip = d.rows.reduce((s, r) => s + (r.precip || 0), 0);
@@ -498,8 +526,13 @@ export function DayTabs({
vehicleHazard = 'ice';
} else if (minVis !== null && minVis < 2) {
vehicleHazard = 'fog';
} else if (allPrecip >= 0.3) {
} else if (allPrecip >= 0.3 && hazMaxProb >= 50) {
// Same probability gate as the main rain icon: a 20% chance
// day must not show rain just because the modelled total is >0.3mm.
vehicleHazard = 'rain'; hazPrecip = allPrecip;
// Medium (2 drops) needs >1mm AND >=50% chance; otherwise light.
// PrecipIcon would otherwise pick drops from the amount alone.
hazDrops = allPrecip > 1 ? 2 : 1;
}
}
@@ -902,7 +935,7 @@ export function DayTabs({
${tempOnly
? (isVehicleProfile
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
: vehicleHazard === 'rain' ? html`<${PrecipIcon} precip=${hazPrecip} snow=${0} size=${30} />`
: vehicleHazard === 'rain' ? html`<${PrecipIcon} precip=${hazPrecip} snow=${0} drops=${hazDrops} size=${30} />`
: vehicleHazard === 'fog' ? html`<${FogIcon} size=${30} />`
: vehicleHazard === 'ice' ? html`<${IceIcon} size=${30} />`
: html`<${CarIcon} size=${30} />`)
+4 -2
View File
@@ -207,8 +207,10 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
);
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; });
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
// First hour's date sets the seasonal ground temperature under the floor.
const startIso = hourlyRows[0]?.iso;
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType, startIso);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType, startIso);
hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
// Vehicle cabin temperature with thermal lag. A parked vehicle climbs
+5 -3
View File
@@ -106,6 +106,7 @@ export const OUTDOORS_VARIANTS = {
// Places - free
park: { name: 'Park / Picnic', scene: "url('assets/images/profiles/park.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
forest: { name: 'Forest / Woodland', scene: "url('assets/images/profiles/forest.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: true } },
camping: { name: 'Camping', scene: "url('assets/images/profiles/camping.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: false, vis: true, aqi: false, pollen: true } },
airport: { name: 'Airport / Travel', scene: "url('assets/images/profiles/airport.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
// Places - Pro
construction: { name: 'Construction', proOnly: true, scene: "url('assets/images/profiles/construction.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
@@ -234,6 +235,7 @@ export const variantIcons = {
events: '🎪',
park: '🌳',
forest: '🌲',
camping: '🏕️',
airport: '✈️',
festival: '⛺',
construction: '🏗️',
@@ -256,7 +258,7 @@ export const variantIcons = {
export const activityVariantKeys = ['dogwalk', 'running', 'cycling', 'hiking', 'fishing', 'sailing', 'wintersports', 'photography', 'naturist'];
// Which variant keys appear in the Places dropdown.
export const placeVariantKeys = ['urban', 'park', 'forest', 'beach', 'events', 'festival', 'airport'];
export const placeVariantKeys = ['urban', 'park', 'forest', 'camping', 'beach', 'events', 'festival', 'airport'];
// Which variant keys appear in the Work dropdown.
export const workVariantKeys = ['farming', 'construction', 'market', 'windowcleaning', 'driver', 'office'];
@@ -291,8 +293,8 @@ export const COL_DESCRIPTIONS = {
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.', short: "Water saturation of the top 1 cm of soil. Above 40% is saturated ground; below 20% is dry.", link: "./columns.html#col-soilM" },
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.', short: "Estimated temperature of sun-exposed paving, which can run 15–25 °C above air temperature.", link: "./columns.html#col-concreteT" },
vehicleT: { title: 'Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.', short: "Estimated cabin temperature inside a parked vehicle. Dangerous for children and pets above 35 °C.", link: "./columns.html#col-vehicleT" },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.', short: "Estimated temperature inside your selected building type with windows closed and no cooling.", link: "./columns.html#col-indoorT" },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.', short: "The same building with curtains closed and windows opened when outside air is cooler — the standard heatwave advice.", link: "./columns.html#col-managedT" },
indoorT: { title: 'Indoors', desc: 'Estimated temperature inside the selected building type when it is empty — windows closed, no heating, no cooling, no occupants. Accounts for window solar gain, the thermal mass of walls and floors (which smooths the daily swing and builds up over a heatwave or cold snap), heat exchange with the ground, and warmth from heated neighbours through party walls in flats and terraces.', short: "Estimated temperature inside your selected building type when empty — windows closed, no heating or cooling.", link: "./columns.html#col-indoorT" },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature when the building is managed sensibly for the weather. On warm days (24 °C+ outside, or 23 °C+ inside) it follows UK heatwave advice: curtains closed against the sun and windows opened whenever outdoor air is cooler, never ventilating below 20 °C. On cooler days windows stay shut, curtains are open by day to let the sun in and closed at night to hold the warmth.', short: "The same building managed for the weather — heatwave advice on warm days, curtains used to hold warmth on cool days.", link: "./columns.html#col-managedT" },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.', short: "Horizontal visibility in km. Below 1 km means fog; below 10 km means mist, smoke or pollution.", link: "./columns.html#col-vis" },
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0–100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 0–20 = Good; 20–40 = Fair; 40–60 = Moderate; 60–80 = Poor; 80–100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.', short: "European Air Quality Index combining PM2.5, PM10, ozone, NO₂ and SO₂. 0–20 is Good, 100+ Extremely Poor.", link: "./columns.html#col-aqi" },
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 10–50; High = 50–200; Very High = 200+. Values vary by species and season.', short: "Pollen concentration in grains per m³ for the selected type. Low is under 10; Very High is 200+.", link: "./columns.html#col-pollen" },
+34
View File
@@ -504,8 +504,11 @@ export function useAppState() {
const variantCols = OUTDOORS_VARIANTS[v]?.cols;
if (variantCols) {
setVisibleCols({ ...variantCols });
// Indoors state is owned here (and persisted) so Quick, Detailed and
// Table all agree - callers must not override it afterwards.
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
setIndoorManagedAndSave(false);
}
// Auto-set UTCI environment modifier to match the selected variant.
const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
@@ -516,6 +519,14 @@ export function useAppState() {
setVehicleTypeAndSave('truck');
setVehicleSpeedAndSave('aroad');
}
// Camping shows the Indoor column as the tent's overnight temperature.
if (v === 'camping') {
setBuildingTypeAndSave('tent');
}
// Office models the Indoor column on an office building.
if (v === 'office') {
setBuildingTypeAndSave('office');
}
};
const activeCols = activeProfile === 'outdoors'
@@ -621,11 +632,21 @@ export function useAppState() {
}
}, []);
const closeWelcome = () => {
// First visit only: hand the new user straight on to the profile picker
// so they start from a profile that matches them rather than the default.
// Reopening the popup later from the footer link doesn't re-trigger it.
let firstVisit = false;
try { firstVisit = localStorage.getItem('sunscope_welcome_seen') !== '1'; } catch (e) { /* ignore */ }
try { localStorage.setItem('sunscope_welcome_seen', '1'); } catch (e) { /* ignore */ }
setWelcomeOpen(false);
if (firstVisit) setProfilePickPending(true);
};
const openWelcome = () => setWelcomeOpen(true);
// Set when the welcome popup is dismissed on a first visit; cleared by the
// effect below that actually opens the picker.
const [profilePickPending, setProfilePickPending] = useState(false);
// ── Weekly subscribe nudge (free users only) ─────────────────────────
// At most once every 7 days, and never on the first visit - the welcome
// popup owns that moment. The last-shown date lives in localStorage;
@@ -660,6 +681,16 @@ export function useAppState() {
const openPanel = () => setPanelOpen(true);
const closePanel = () => setPanelOpen(false);
// Completes the first-visit hand-off from the welcome popup, but only once
// the forecast is in: the panel anchors itself to the active-profile row,
// which doesn't exist until the forecast has rendered.
useEffect(() => {
if (!profilePickPending || !forecast) return;
setProfilePickPending(false);
setPanelOpen(true);
track('profile_picker_first_visit');
}, [profilePickPending, forecast]);
const [showUnits, setShowUnits] = useState(() => {
try {
const s = localStorage.getItem('sunscope_show_units');
@@ -778,6 +809,7 @@ export function useAppState() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
} = useColumnPopup();
const {
@@ -791,6 +823,7 @@ export function useAppState() {
// The rotated table is a single table with CSS-only sticky edges, so
// none of this hook's dual-table syncing applies to it.
rotated: tableRotated,
tableInterval,
});
// ── 8. GEOCODING SEARCH ───────────────────────────────────────────────
@@ -953,6 +986,7 @@ export function useAppState() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
// table scroll
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
// computation
+68 -9
View File
@@ -23,7 +23,10 @@ export function useColumnPopup() {
const closeTimerRef = useRef(null);
// --- Event tag popup ------------------------------------------------
// Holds { events[], slideIndex, x, y, arrowLeft, below }
// Holds { events[], date, slideIndex, x, y, arrowLeft, below }
// `date` is the YYYY-MM-DD of the row the tag was on - the popup's Quick
// Record button dates its journal entry to the day being looked at, which
// is not necessarily today (the table can be showing any of the 14 days).
// When multiple events are in the popup they auto-cycle with a crossfade.
const [eventTagPopup, setEventTagPopup] = useState(null);
const [evSlideIndex, setEvSlideIndex] = useState(0);
@@ -168,12 +171,12 @@ export function useColumnPopup() {
}, [eventTagPopup, evSlideTo]);
// --- Event tag popup handlers ----------------------------------------
const openEventTagPopup = (events, spanEl, startIndex = 0) => {
const openEventTagPopup = (events, spanEl, startIndex = 0, date = null) => {
clearInterval(evSlideTimerRef.current);
setEvSlideIndex(startIndex);
prevSlideIndexRef.current = startIndex;
setEvTransition(null);
setEventTagPopup({ events, ...calcEventPopupPos(spanEl) });
setEventTagPopup({ events, date, ...calcEventPopupPos(spanEl) });
};
const closeEventTagPopup = () => {
@@ -185,8 +188,9 @@ export function useColumnPopup() {
clearInterval(evSlideTimerRef.current);
};
// rowEvents = all events for that row (passed in from app.js)
const handleEventTagClick = (rowEvents, startIndex, e) => {
// rowEvents = all events for that row, dateISO = that row's YYYY-MM-DD
// (both passed in from app.js)
const handleEventTagClick = (rowEvents, startIndex, dateISO, e) => {
e.stopPropagation();
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
@@ -194,19 +198,27 @@ export function useColumnPopup() {
if (eventTagPopup && JSON.stringify(eventTagPopup.events.map(ev => ev.id)) === JSON.stringify(rowEvents.map(ev => ev.id))) {
closeEventTagPopup(); return;
}
openEventTagPopup(rowEvents, e.currentTarget, startIndex);
openEventTagPopup(rowEvents, e.currentTarget, startIndex, dateISO);
};
const handleEventTagEnter = (rowEvents, startIndex, e) => {
const handleEventTagEnter = (rowEvents, startIndex, dateISO, e) => {
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
const spanEl = e.currentTarget;
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex), 700);
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex, dateISO), 700);
};
const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current);
const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current);
// Moving into the popup stops the carousel for good (leaving it closes the
// popup anyway, so there is nothing to restart). It has a Quick Record
// button in it now: an event that slides out from under the cursor mid-reach
// would file the wrong one.
const handleEventTagPopupEnter = () => {
clearTimeout(evCloseTimerRef.current);
clearInterval(evSlideTimerRef.current);
};
const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); };
useEffect(() => {
@@ -239,12 +251,59 @@ export function useColumnPopup() {
}
}, [eventTagPopup]);
// --- Week Ahead row popup ------------------------------------------
// Click-driven only: the Week Ahead rows used to carry their
// explanation in `title=` tooltips, which never surface on touch.
// Holds { key, title, sub, rows:[{icon,text}], ...pos }.
const [weekPopup, setWeekPopup] = useState(null);
const weekPopupRef = useRef(null);
const closeWeekPopup = () => setWeekPopup(null);
const handleWeekRowClick = (payload, e) => {
e.stopPropagation();
if (weekPopup?.key === payload.key) { closeWeekPopup(); return; }
setWeekPopup({ ...payload, ...calcPopupPos(e.currentTarget) });
};
useEffect(() => {
if (!weekPopup) return;
const onClickOutside = (e) => {
if (weekPopupRef.current && !weekPopupRef.current.contains(e.target)) closeWeekPopup();
};
const onScrollOrResize = () => closeWeekPopup();
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
window.addEventListener('resize', onScrollOrResize, { passive: true });
return () => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('scroll', onScrollOrResize, { capture: true });
window.removeEventListener('resize', onScrollOrResize);
};
}, [weekPopup]);
// Post-render clamp (same logic as the col popup above).
useEffect(() => {
if (!weekPopup || !weekPopupRef.current) return;
const rect = weekPopupRef.current.getBoundingClientRect();
const MARGIN = 8;
if (!weekPopup.below && rect.top < MARGIN) {
const delta = MARGIN - rect.top;
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y + delta } : prev);
} else if (weekPopup.below && rect.bottom > window.innerHeight - MARGIN) {
const delta = rect.bottom - (window.innerHeight - MARGIN);
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y - delta } : prev);
}
}, [weekPopup]);
return {
// column-header popup
colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave,
handlePopupEnter, handlePopupLeave,
closePopup,
// week-ahead row popup
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
// event-tag popup
eventTagPopup, eventTagPopupRef,
evSlideIndex, evTransition, evSlideTo,
+46 -6
View File
@@ -44,6 +44,8 @@ export function useTableScroll({
// are a sticky header table over a horizontally scrolling body table.
// It is a dependency purely so the widths re-sync on the flip.
rotated = false,
// Row bucket size (1-4h). Changes the number of rows/columns, so widths must re-sync.
tableInterval = 1,
}) {
// --- SCROLL INDICATORS ---------------------------------------------
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
@@ -167,6 +169,10 @@ export function useTableScroll({
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
headTable.style.width = 'max-content';
bodyTable.style.width = 'max-content';
// Drop the CSS min-width:100% while measuring, or auto layout stretches the
// columns to fill the container and the "natural" widths come out inflated.
headTable.style.minWidth = '0';
bodyTable.style.minWidth = '0';
headTable.style.tableLayout = 'auto';
bodyTable.style.tableLayout = 'auto';
@@ -176,7 +182,15 @@ export function useTableScroll({
let naturalTotal = 0;
for (let i = 0; i < n; i++) {
const headW = headCells[i].getBoundingClientRect().width;
const bodyW = bodyCells[i].getBoundingClientRect().width;
let bodyW = bodyCells[i].getBoundingClientRect().width;
// Rotated: the first body row is an empty group-label row, so take
// the widest cell in this column across every body row instead.
if (rotated) {
bodyTable.querySelectorAll('tbody tr').forEach(tr => {
const c = tr.children[i];
if (c) bodyW = Math.max(bodyW, c.getBoundingClientRect().width);
});
}
const w = Math.max(Math.ceil(headW), Math.ceil(bodyW));
naturalW[i] = w;
naturalTotal += w;
@@ -186,14 +200,40 @@ export function useTableScroll({
const containerW = bodyScroll.clientWidth;
const finalW = new Array(n);
let totalWidth;
if (naturalTotal > 0 && naturalTotal < containerW) {
// Rotated: pinned label column keeps its natural width and the hour
// columns are sized EQUALLY to exactly fill the scroller - both
// spreading out when there are few and shrinking (down to the 92px
// cell minimum) so they never run off the right edge. Only when even
// that floor doesn't fit does it fall through to natural widths + scroll.
const eqW = rotated && n > 1 ? Math.floor((containerW - naturalW[0]) / (n - 1)) : 0;
if (rotated && n > 1 && eqW >= 92) {
let running = naturalW[0];
finalW[0] = naturalW[0];
for (let i = 1; i < n - 1; i++) { finalW[i] = eqW; running += eqW; }
finalW[n - 1] = containerW - running; // absorb rounding
totalWidth = containerW;
} else if (naturalTotal > 0 && naturalTotal < containerW) {
// Spare space - distribute proportionally across columns so they
// fan out to fill the scroller (no awkward right-hand gap).
const scale = containerW / naturalTotal;
let running = 0;
for (let i = 0; i < n - 1; i++) {
finalW[i] = Math.floor(naturalW[i] * scale);
running += finalW[i];
if (rotated && n > 1) {
// Rotated: keep the pinned label column at its natural width and
// share the spare space EQUALLY across the hour columns, so 2h/3h/4h
// spread evenly instead of scaling each column by its own width.
const spare = containerW - naturalTotal;
const share = Math.floor(spare / (n - 1));
finalW[0] = naturalW[0];
running = finalW[0];
for (let i = 1; i < n - 1; i++) {
finalW[i] = naturalW[i] + share;
running += finalW[i];
}
} else {
for (let i = 0; i < n - 1; i++) {
finalW[i] = Math.floor(naturalW[i] * scale);
running += finalW[i];
}
}
// Absorb sub-pixel rounding into the last column so the total
// exactly matches the container width.
@@ -251,7 +291,7 @@ export function useTableScroll({
if (ro) ro.disconnect();
window.removeEventListener('resize', sync);
};
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated]);
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated, tableInterval]);
return {
tableCanScrollLeft,
+189 -102
View File
@@ -233,39 +233,58 @@ export function calcConcreteTempPass(TaArr, radArr, vaArr, elevArr, uvArr, cloud
}
// -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE (windows closed, no active cooling)
// UK HOUSE INDOOR TEMPERATURE - TWO-NODE THERMAL MODEL
// -------------------------------------------------------------------
// Estimates the ambient indoor air temperature of a typical UK brick
// house with windows closed and no air conditioning.
// Estimates the free-running indoor air temperature of the selected
// BUILDING_TYPES preset when EMPTY: no heating, no air conditioning, no
// occupants - only standby appliances (fridge, router) as internal gain.
//
// Two heat pathways are modelled:
// Each hour builds an outdoor-driven TARGET - where the room would settle
// if this hour's conditions held forever:
//
// 1. WALL CONDUCTION
// Heat conducts through brick cavity walls and roof. UK Part L
// compliant walls have a U-value around 0.28-0.45 W/m-K; older
// solid-brick stock runs higher. A representative mid-stock value
// is used. This drives a slow, steady heat transfer proportional
// to the difference between outdoor and indoor air temperature.
// target = outdoor ref + solar lift + standby gain
//
// 2. WINDOW SOLAR GAIN
// A typical UK semi has ~15-18% glazing ratio. Solar energy
// transmits through glass, is absorbed by floors and furniture,
// and heats the indoor air. Gain is averaged across orientations
// (not all windows face south). Diffuse radiation contributes
// regardless of sun angle.
// outdoor ref blend of outside air, the ground and heated neighbours:
// Ta x (1 - groundCoupling - neighbourCoupling)
// + ground temp x groundCoupling
// + neighbour temp x neighbourCoupling
// Heat lost through the floor goes to the ground, not the
// air, and UK ground under a building sits near 6.5-14.5 C
// through the year (ukGroundTempC). This is what keeps an
// empty house above freezing in a cold snap and a little
// cooler in a heatwave - with a FIXED gap per degree, not a
// thermostat-like one.
// Neighbours (party walls/floors of terraces and flats) are
// occupied homes: never below NEIGHBOUR_MIN_C (they heat), and
// otherwise assumed the same as this building, so no flow.
// solar lift glob x glazingRatio x gValue x orientFactor x solarScale
// (tent: glazingRatio stands in for the sunlit fabric)
// standby gain fridge/router/standby loads in an empty building (C)
//
// THERMAL LAG
// Brick and concrete have high thermal mass - the house responds
// slowly to outdoor temperature swings. Each hour builds a realistic
// target temperature from outdoor air, window solar gain, retained
// warmth, and internal gains, then the room temperature lags toward
// that target. This avoids runaway accumulation while still giving
// the characteristic late-day indoor peak.
// Call calcIndoorTempPass() on the full hourly arrays after
// building rows - it returns a per-hour indoor temp array.
// The room is then modelled as TWO coupled nodes rather than one lag:
//
// No mechanical cooling. Minimal infiltration (windows closed).
// Internal heat gains (people, appliances) are not modelled.
// 1. ROOM AIR (Ti) - fast node, time constant lagHours. Its equilibrium is
// a blend of the outdoor-driven target and the thermal mass:
// airEq = (1 - massCoupling) x target + massCoupling x Tm
// massCoupling is how strongly the walls/floors hold the air compared
// with the outdoor pull through glass, fabric and leakage.
//
// 2. THERMAL MASS (Tm) - slow node, time constant massHours. Walls, floors
// and furniture exchange heat with the room air.
//
// Why two nodes: a single exponential lag lets ~70% of the outdoor daily
// swing through for a 4 h brick constant, so a brick house swung 21-34 C on
// an 18-32 C day. Monitored UK homes swing roughly a third of the outdoor
// range and instead RATCHET UP over a multi-day heatwave as the mass warms -
// the slow node reproduces both. The effective outdoor time constant of the
// mass is massHours / (1 - massCoupling) - about 1.5 days for brick.
//
// The forecast starts at midnight with no history, so the model is spun up
// by cycling the first day SPIN_UP_DAYS times (assumes the days before the
// forecast resembled the first one) instead of starting the mass cold.
//
// Indoor CAN be cooler than outdoor (a stone cottage on a hot afternoon);
// the only clamp is a 55 C physical cap.
//
// Colour thresholds:
// < 20 -C - cool, may need heating
@@ -274,94 +293,162 @@ export function calcConcreteTempPass(TaArr, radArr, vaArr, elevArr, uvArr, cloud
// > 32 -C - hot; risk for elderly and vulnerable occupants
// -------------------------------------------------------------------
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
// Returns an array of indoor temperatures, one per hour.
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const { lagHours, glazingRatio, gValue, orientFactor, solarScale, baseTemp, internalGain, retainedScale } = preset;
// Days of the first forecast day replayed to warm up the thermal mass.
const SPIN_UP_DAYS = 5;
// Managed mode switches to the heatwave routine on any day forecast to reach
// this outdoor temperature, or whenever the room itself passes HOT_INDOOR_C.
const HOT_DAY_OUTDOOR_C = 24;
const HOT_INDOOR_C = 23;
// Windows are only opened to cool while the room is above this, so night
// ventilation never chills the house below a comfortable sleeping temp.
const VENT_FLOOR_C = 20;
// UK ground temperature at ~1 m under a building for a day of year.
// Annual mean ~10.5 C, swing +/-4 C, coldest early March, warmest early
// September (the ground lags the air by ~6 weeks). UK/northern-hemisphere
// climate assumed, matching the rest of the building model.
const GROUND_MEAN_C = 10.5;
const GROUND_SWING_C = 4;
const GROUND_COLDEST_DOY = 65;
// Occupied neighbours keep their homes at least this warm in cold weather.
const NEIGHBOUR_MIN_C = 18;
function ukGroundTempC(dayOfYear) {
return GROUND_MEAN_C - GROUND_SWING_C * Math.cos(2 * Math.PI * (dayOfYear - GROUND_COLDEST_DOY) / 365);
}
// One hour of the indoor model. Mutates and returns state { Ti, Tm }.
// managed - false: windows closed, curtains open all the time (passive)
// true : occupant follows the routine for the conditions -
// HEAT (hotDay): curtains closed while the sun is up,
// windows opened whenever outdoor air is cooler
// COOL: windows shut, curtains open by day to let the sun
// in, closed after dark to cut window heat loss
function indoorStep(state, Ta, glob, preset, managed, hotDay, groundTemp) {
const { lagHours, massHours, massCoupling, glazingRatio, gValue, orientFactor,
curtainBlock, curtainInsul, ventAlpha, solarScale, groundCoupling,
neighbourCoupling, internalGain } = preset;
const heatMode = managed && (hotDay || state.Ti >= HOT_INDOOR_C);
const coolMode = managed && !heatMode;
// Solar gain - only blocked when managing for heat and the sun is up.
const block = heatMode ? curtainBlock : 0;
const solarLift = glob * glazingRatio * gValue * orientFactor * solarScale * (1 - block);
// Outdoor pull is split between outside air, the ground under the floor
// and any heated neighbours through party walls/floors.
const neighbourTemp = Math.max(state.Ti, NEIGHBOUR_MIN_C);
const outdoorRef = (1 - groundCoupling - neighbourCoupling) * Ta
+ groundCoupling * groundTemp
+ neighbourCoupling * neighbourTemp;
let target = outdoorRef + solarLift + internalGain;
// Cool routine after dark: closed curtains cut window heat loss, so the
// room is pulled less hard toward outdoor - curtainInsul is the share of
// the outdoor heat loss they save.
if (coolMode && glob <= 0 && target < state.Ti) {
target += (state.Ti - target) * curtainInsul;
}
// Room air relaxes toward a blend of the outdoor pull and the mass.
const airEq = (1 - massCoupling) * target + massCoupling * state.Tm;
const aAir = 1 - Math.exp(-1 / lagHours);
const aMass = 1 - Math.exp(-1 / massHours);
state.Ti += aAir * (airEq - state.Ti);
// Heat routine: open windows only when outside is cooler and the room is
// still above the comfort floor; never ventilate below that floor.
if (heatMode && Ta < state.Ti && state.Ti > VENT_FLOOR_C) {
const vented = state.Ti + ventAlpha * (Ta - state.Ti);
state.Ti = Math.max(vented, VENT_FLOOR_C, Ta);
}
// Mass exchanges with the (possibly ventilated) room air - this is how
// night purging cools the fabric for the next day.
state.Tm += aMass * (state.Ti - state.Tm);
return state;
}
// Shared driver for the passive and managed passes.
// Hourly arrays are assumed to start at local midnight (forecast_days fetch),
// so hour i belongs to forecast day floor(i / 24).
// startIso is the first hour's ISO time (for the seasonal ground temp); if
// missing the annual mean ground temperature is used.
function runIndoorModel(TaArr, globArr, buildingType, managed, startIso) {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const n = TaArr.length;
const result = new Array(n);
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
// Seed to a plausible occupied indoor baseline rather than outdoor air.
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
// Ground changes only ~0.5 C over a 14-day forecast - one value is enough.
const start = startIso ? new Date(startIso.slice(0, 10) + 'T00:00:00Z') : null;
const doy = start && !isNaN(start)
? Math.floor((start - Date.UTC(start.getUTCFullYear(), 0, 1)) / 86400000) + 1
: null;
const groundTemp = doy != null ? ukGroundTempC(doy) : GROUND_MEAN_C;
// Daily outdoor maxima decide the managed routine for each forecast day.
const dayMax = [];
for (let i = 0; i < n; i++) {
const d = Math.floor(i / 24);
const Ta = TaArr[i];
if (Ta != null) dayMax[d] = Math.max(dayMax[d] ?? -Infinity, Ta);
}
const isHotDay = i => (dayMax[Math.floor(i / 24)] ?? -Infinity) >= HOT_DAY_OUTDOOR_C;
// Seed both nodes at the first hour's air temp, then spin up on day one.
const seed = TaArr[0] ?? groundTemp;
const state = { Ti: seed, Tm: seed };
const spinHours = Math.min(24, n);
for (let rep = 0; rep < SPIN_UP_DAYS; rep++) {
for (let i = 0; i < spinHours; i++) {
indoorStep(state, TaArr[i] ?? state.Ti, globArr[i] ?? 0, preset, managed, isHotDay(i), groundTemp);
}
}
for (let i = 0; i < n; i++) {
const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0;
// Solar gain through windows (W/m- effective)
const solarGain = glob * glazingRatio * gValue * orientFactor;
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
// Apply thermal lag: blend toward the hour's target instead of adding
// solar gain repeatedly onto the previous indoor temperature.
Ti = Ti + alpha * (target - Ti);
// Can't be colder than outdoor (house doesn't actively cool)
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
const Ta = TaArr[i] ?? state.Ti;
indoorStep(state, Ta, globArr[i] ?? 0, preset, managed, isHotDay(i), groundTemp);
result[i] = Math.min(state.Ti, 55);
}
return result;
}
// -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE - MANAGED (curtains closed, windows open)
// -------------------------------------------------------------------
// Models the same UK brick house as calcIndoorTempPass but with two
// behavioural interventions that reflect standard heatwave advice:
//
// 1. CURTAINS CLOSED
// Thick curtains block ~80% of window solar gain before it enters
// the room. The small remaining fraction is diffuse light through
// the curtain fabric. Wall conduction is unchanged.
//
// 2. WINDOWS OPEN (smart ventilation)
// When outdoor air is cooler than the indoor air, windows are open
// and a ventilation heat exchange pulls the indoor temp toward
// outdoor. When outdoor is hotter than indoor, windows are kept
// shut - so this strategy never makes things worse, only better.
// Ventilation rate ~2 air changes/hour for a well-opened house.
//
// Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
// -------------------------------------------------------------------
// PASSIVE: empty building, windows closed, curtains open, no heating or cooling.
// Returns an array of indoor temperatures, one per hour.
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const { lagHours, glazingRatio, gValue, orientFactor, curtainBlock, ventAlpha, solarScale, baseTemp, internalGain, retainedScale } = preset;
// elevArr is accepted for signature compatibility but not needed - the
// orientFactor average already covers sun angle on the glazing.
// startIso (optional) - first hour's ISO time, sets the seasonal ground temp.
export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick', startIso) {
return runIndoorModel(TaArr, globArr, buildingType, false, startIso);
}
const n = TaArr.length;
const result = new Array(n);
const alpha = 1 - Math.exp(-1 / lagHours);
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
for (let i = 0; i < n; i++) {
const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0;
// Solar gain - curtains block curtainBlock fraction
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
// Apply thermal lag toward the managed target.
Ti = Ti + alpha * (target - Ti);
// Smart ventilation: only open windows when outside is cooler
if (Ta < Ti) {
Ti = Ti + ventAlpha * (Ta - Ti);
}
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
}
return result;
// -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE - MANAGED
// -------------------------------------------------------------------
// The same empty building, but with its curtains and windows set sensibly
// for the conditions (by a visiting owner or smart blinds - see indoorStep):
//
// HEAT routine (day forecast >= HOT_DAY_OUTDOOR_C, or room >= HOT_INDOOR_C)
// Standard UK heatwave advice. Curtains/blinds closed while the sun is up
// (curtainBlock - internal curtains only stop ~40-55% because the heat
// is already through the glass; external shutters would be ~80%+).
// Windows opened whenever outdoor air is cooler than inside, down to a
// VENT_FLOOR_C comfort floor - so it never makes things worse.
//
// COOL routine (all other days)
// Windows shut. Curtains open in daylight to bank free solar gain,
// closed after dark to cut window heat loss (curtainInsul). Managed is
// therefore slightly WARMER than passive on cool days, not colder.
// -------------------------------------------------------------------
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick', startIso) {
return runIndoorModel(TaArr, globArr, buildingType, true, startIso);
}
// -------------------------------------------------------------------
+158 -35
View File
@@ -197,7 +197,20 @@ export function petCategory(t) {
// alarm - is shared with the table palettes above, so the two can never
// drift apart on anything but this one deliberate colour.
// -------------------------------------------------------------------
const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8e554
// bandRampRgb() draws a straight line between each pair of neighbouring
// anchors, so three anchors only look like one smooth ramp if the middle one
// sits ON the line between the other two. A hand-picked "one step under
// Warm" gold does not: it already carries Warm's full saturation, so Cool
// (15C, oatmeal) to Comfortable (19C) is an abrupt jump to solid gold and
// Comfortable to Warm (24C) is then almost flat - which is exactly the "very
// yellow" plateau a mild week (19-24C highs) sits in. Setting Comfortable to
// the actual RGB midpoint between Cool's oatmeal and Warm's yellow removes
// the kink, so the same mild week instead reads as a continuous fade.
const DAY_TAB_HUMAN_COMFORTABLE = '#eade92';
// Same fix, recomputed for the pet scale's own Cool (11C, #b5dee9) and Warm
// (32C, #f7cd54) anchors - the pet ramp spans different thresholds, so the
// human midpoint would land in the wrong place on it.
const DAY_TAB_PET_COMFORTABLE = '#e1d386';
// The same reasoning, carried down the rest of the HUMAN scale.
//
@@ -217,18 +230,18 @@ const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8
// (pet Cool is #b5dee9, not a green), and its thresholds sit at completely
// different temperatures, so it keeps the Comfortable override alone.
const DAY_TAB_HUMAN = {
Comfortable: DAY_TAB_COMFORTABLE,
Comfortable: DAY_TAB_HUMAN_COMFORTABLE,
Cool: '#ded9c3', // warm oatmeal, one step under Comfortable's gold
Chilly: '#d3d5d2', // the neutral pivot between the warm and cold halves
Cold: '#c2d3dd', // first step back into blue
};
const withTabComfort = (bands) =>
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: DAY_TAB_COMFORTABLE } : b));
const withTabComfort = (bands, comfortableBg) =>
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: comfortableBg } : b));
export const DAY_TAB_UTCI_BANDS =
UTCI_BANDS.map(b => (DAY_TAB_HUMAN[b.label] ? { ...b, bg: DAY_TAB_HUMAN[b.label] } : b));
export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS);
export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS, DAY_TAB_PET_COMFORTABLE);
// Diagonal sweep gradient for a band background hex colour.
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
@@ -392,40 +405,66 @@ export const VEHICLE_SPEEDS = {
};
// -------------------------------------------------------------------
// BUILDING TYPES - presets for the indoor temperature model.
// BUILDING TYPES - presets for the two-node indoor temperature model.
// -------------------------------------------------------------------
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass.
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass
// (see physics.js). Every building is modelled EMPTY - no heating, no
// occupants, only standby appliances. Heavy masonry swings ~3-4 C a day and
// ratchets up over a heatwave; lightweight timber and top-floor flats run
// hottest; in a prolonged hard frost empty houses settle near 0 C, with
// terraces and flats held up by heated neighbours.
//
// uWall W/m-K Effective envelope U-value. Higher = faster response
// to outdoor swings, less insulation.
// lagHours h Thermal mass time constant. Heavier construction = longer
// lag before indoor temp follows outdoor changes.
// glazingRatio - Fraction of floor area that is window. More glass = more
// solar gain in summer, more heat loss in winter.
// gValue - Solar heat gain coefficient of glazing. 0.63 = standard
// double glazing; 0.3 = modern low-e triple.
// orientFactor - Fraction of windows facing the sun at any given time.
// 0.5 = random orientation; 0.8 = south-facing conservatory.
// curtainBlock - Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains - 0.80; blinds - 0.50.
// ventAlpha - Blending weight per hour when smart ventilation is open.
// Higher = more air changes per hour.
// solarScale - Converts effective window solar gain into an indoor
// temperature lift. Lower values mean more thermal mass.
// baseTemp - Occupied/retained warmth baseline for normal homes.
// internalGain - Small heat gain from people, appliances, and background use.
// retainedScale - How strongly the building holds above-outdoor warmth in
// cool conditions. Higher = better retained warmth.
// lagHours h Room AIR response time - how fast the air follows its
// equilibrium. Short for every building; the slow part of
// the response is the mass node below.
// massHours h Air-to-mass exchange time constant of walls/floors.
// massCoupling - How strongly the mass holds the room air (0-1) versus the
// outdoor pull through glass, fabric and leakage. Higher =
// heavier/better insulated = smaller daily swing. Effective
// outdoor time constant is massHours / (1 - massCoupling):
// ~17 h timber ... ~30 h brick ... ~53 h modern and stone.
// glazingRatio - Fraction of floor area that is window (tent: sunlit fabric).
// gValue - Solar heat gain coefficient of glazing. ~0.63 older double
// glazing; ~0.50 typical UK low-e double; ~0.30 solar-control.
// orientFactor - Fraction of windows facing the sun at any given time.
// 0.5 = random orientation; 0.7 = south-facing conservatory.
// curtainBlock - Fraction of solar gain blocked by the managed heat routine.
// Internal curtains/blinds only stop ~40-55% because the heat
// is already through the glass (external shutters ~80%+).
// curtainInsul - Share of OUTDOOR heat loss saved by closing curtains after
// dark in the managed cool routine (windows carry ~25-40% of
// it; lined curtains cut that by about a third).
// ventAlpha - Per-hour blend toward outdoor air when windows are opened
// in the managed heat routine. Higher = more air changes.
// solarScale - Converts effective window solar gain into a temperature
// lift on the room's outdoor-driven target. Higher for
// better-insulated shells (same gain, less heat loss).
// groundCoupling - Share of the outdoor heat loss that goes through the
// floor to the ground (seasonal UK ground temp) instead of
// to outside air. 0 for flats above ground and tents.
// neighbourCoupling - Share lost through party walls/floors to occupied
// neighbours, who never let their homes fall below 18 C.
// Highest for flats and terraces; 0 for detached/tents.
// internalGain - Standby gain of the EMPTY building - fridge, router,
// standby loads (C). Larger where the shell loses less heat.
// -------------------------------------------------------------------
export const BUILDING_TYPES = {
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25, solarScale: 0.10, baseTemp: 16.5, internalGain: 0.8, retainedScale: 0.35 },
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.55 },
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30, solarScale: 0.10, baseTemp: 16.0, internalGain: 0.7, retainedScale: 0.25 },
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25, solarScale: 0.08, baseTemp: 15.5, internalGain: 0.6, retainedScale: 0.40 },
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.45 },
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20, solarScale: 0.11, baseTemp: 17.0, internalGain: 0.9, retainedScale: 0.45 },
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50, solarScale: 0.045, baseTemp: 12.0, internalGain: 0.2, retainedScale: 0.05 },
office: { name: 'Office Block', uWall: 0.25, lagHours: 2, glazingRatio: 0.45, gValue: 0.55, orientFactor: 0.65, curtainBlock: 0.50, ventAlpha: 0.15, solarScale: 0.055, baseTemp: 18.5, internalGain: 2.5, retainedScale: 0.30 },
brick: { name: 'Brick (typical)', lagHours: 1.5, massHours: 6, massCoupling: 0.80, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.55, curtainInsul: 0.12, ventAlpha: 0.25, solarScale: 0.10, groundCoupling: 0.15, neighbourCoupling: 0.10, internalGain: 0.4 },
// Modern: airtight and insulated, typical low-e double glazing - loses heat
// slowly, so it holds warmth in a cold snap and accumulates heat over a
// long heatwave.
modern: { name: 'Modern / Well insulated', lagHours: 1.5, massHours: 8, massCoupling: 0.85, glazingRatio: 0.20, gValue: 0.50, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.10, ventAlpha: 0.20, solarScale: 0.14, groundCoupling: 0.12, neighbourCoupling: 0.10, internalGain: 1.0 },
victorian: { name: 'Victorian Terrace', lagHours: 1.5, massHours: 6, massCoupling: 0.75, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.55, curtainInsul: 0.15, ventAlpha: 0.30, solarScale: 0.10, groundCoupling: 0.08, neighbourCoupling: 0.20, internalGain: 0.3 },
stone: { name: 'Stone / Granite Cottage', lagHours: 2, massHours: 8, massCoupling: 0.85, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.10, ventAlpha: 0.25, solarScale: 0.08, groundCoupling: 0.20, neighbourCoupling: 0.00, internalGain: 0.3 },
timber: { name: 'Timber Frame / New Build', lagHours: 1, massHours: 6, massCoupling: 0.65, glazingRatio: 0.22, gValue: 0.50, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.12, ventAlpha: 0.35, solarScale: 0.08, groundCoupling: 0.12, neighbourCoupling: 0.05, internalGain: 0.6 },
flat: { name: 'Top-floor Flat', lagHours: 1, massHours: 5, massCoupling: 0.70, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.12, ventAlpha: 0.20, solarScale: 0.11, groundCoupling: 0.00, neighbourCoupling: 0.25, internalGain: 0.6 },
conservatory:{ name: 'Conservatory / Sun Room', lagHours: 0.5, massHours: 3, massCoupling: 0.30, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, curtainInsul: 0.25, ventAlpha: 0.50, solarScale: 0.045, groundCoupling: 0.10, neighbourCoupling: 0.00, internalGain: 0.0 },
office: { name: 'Office Block', lagHours: 1, massHours: 8, massCoupling: 0.70, glazingRatio: 0.45, gValue: 0.55, orientFactor: 0.65, curtainBlock: 0.40, curtainInsul: 0.05, ventAlpha: 0.15, solarScale: 0.055, groundCoupling: 0.08, neighbourCoupling: 0.00, internalGain: 1.0 },
// Tent: glazingRatio stands in for the sun-absorbing fabric area, not windows,
// giving ~+12 °C over ambient at 800 W/m² when closed up. Almost no mass;
// "managed" means doors open / shaded, curtainInsul 0 (nothing to close).
// Empty tent: no body heat, so at night it sits near outdoor air temp.
tent: { name: 'Tent', lagHours: 0.5, massHours: 1, massCoupling: 0.05, glazingRatio: 0.20, gValue: 0.85, orientFactor: 0.60, curtainBlock: 0.30, curtainInsul: 0.00, ventAlpha: 0.60, solarScale: 0.15, groundCoupling: 0.00, neighbourCoupling: 0.00, internalGain: 0.0 },
};
// -------------------------------------------------------------------
@@ -602,6 +641,90 @@ export function skyFillForElev(e, isRising = false) {
return skyGradientForElev(e, isRising).bot;
}
// -------------------------------------------------------------------
// SUN LIGHT STATE - physics-lite sun colour, glow and afterglow.
// -------------------------------------------------------------------
// Shared by SkyScope (hourly mini scopes) and ScopeReticle (big scope)
// so both rise/set identically. Inputs are one forecast row's values;
// hourly rows are computed at the top of the hour (17:00, not 17:10).
//
// Colour: the lower the sun, the more air its light crosses (air mass,
// Kasten-Young). Each RGB channel is dimmed by Rayleigh scattering (blue
// far more than red) plus haze aerosol estimated from visibility (or
// humidity when visibility is missing), so the sun runs white -> yellow
// -> orange -> red, and redder still on hazy evenings.
//
// Returns:
// core - sun disk colour (hex)
// glow - halo / afterglow colour (hex)
// diskAlpha - disk opacity (cloud in front of the sun dims it)
// skyGlow - 0..1 strength of the horizon glow in the sky
// groundGlow - 0..1 strength of the warm wash on the ground
// haze - 0..1, widens and softens the glow
// -------------------------------------------------------------------
const SUN_TAU_RAYLEIGH = [0.045, 0.09, 0.19]; // R,G,B optical depth at air mass 1 (~650/550/450nm)
const SUN_AEROSOL_SPECTRAL = [0.80, 1.0, 1.30]; // aerosol depth per channel relative to 550nm (Angstrom ~1.3)
const AEROSOL_LAYER_KM = 1.2; // haze layer depth used to turn visibility into optical depth
const SUN_DISPLAY_GAMMA = 0.5; // perceptual compression so low sun reads orange, not pure red
// Smooth 0..1 ramp between a and b.
function smoothstep(a, b, x) {
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
// Relative path length through the atmosphere (1 overhead, ~38 at the horizon).
function airMass(elev) {
const h = Math.max(0, elev);
return 1 / (Math.sin(h * Math.PI / 180) + 0.50572 * Math.pow(h + 6.07995, -1.6364));
}
export function sunLightState({ elev, isRising = false, cc = null, ccLow = null, ccMid = null,
ccHigh = null, visKm = null, rh = null }) {
// Haze: visibility -> aerosol optical depth (Koschmieder 3.912/vis).
// Without visibility, guess it from humidity (moist air = hazier).
let vis = visKm;
if (vis == null) vis = rh != null ? Math.max(4, Math.min(40, 40 - Math.max(0, rh - 50) * 0.75)) : 25;
const aod = Math.max(0.05, Math.min(1.5, AEROSOL_LAYER_KM * 3.912 / Math.max(0.5, vis)));
const haze = Math.max(0, Math.min(1, (aod - 0.15) / 0.8));
// Transmitted sunlight per channel, normalised so the brightest is 255.
const am = airMass(elev);
const trans = SUN_TAU_RAYLEIGH.map((tau, i) => Math.exp(-(tau + aod * SUN_AEROSOL_SPECTRAL[i]) * am));
const sunRgb = trans.map(t => 255 * Math.pow(t / trans[0], SUN_DISPLAY_GAMMA));
// Cloud: low cloud blocks the sun and the horizon light; mid/high cloud
// over a clear low sky catches the colour ("fire sky").
const low = ccLow != null ? ccLow : (cc != null ? cc * 0.6 : 0);
const mid = ccMid != null ? ccMid : 0;
const high = ccHigh != null ? ccHigh : 0;
const lowBlock = Math.min(1, low / 100);
const block = Math.min(1, (low * 0.8 + mid * 0.45 + high * 0.15) / 100);
const fire = Math.min(1, (mid + high) / 80) * (1 - lowBlock);
// Elevation envelopes: the sky glow builds as the sun drops below ~14 deg,
// peaks at the horizon and lingers through civil twilight (to -9 deg).
// The ground only gets direct warm light while the sun is up, so its
// wash fades out quickly once the sun has gone (by -4 deg).
const lowSun = 1 - smoothstep(2, 14, elev);
const skyEnv = lowSun * smoothstep(-9, -1, elev);
const groundEnv = (1 - smoothstep(3, 14, elev)) * smoothstep(-4, 0.5, elev);
const skyGlow = Math.min(1, 0.75 * skyEnv * (1 - 0.7 * lowBlock) * (1 + 0.45 * fire));
const groundGlow = Math.min(1, 0.6 * groundEnv * (1 - 0.85 * lowBlock));
// Glow colour: the sun's own colour, shifting rose (dawn) or violet (dusk)
// as the sun sinks deeper, and pinker under a fire sky.
const sunHex = rgbToHex(sunRgb);
const depth = Math.max(0, Math.min(1, -elev / 8));
let glow = rgbToHex(lerpRgb(sunRgb, hexToRgb(isRising ? '#c8607a' : '#a85a9e'), depth * 0.8));
glow = rgbToHex(lerpRgb(hexToRgb(glow), hexToRgb('#ff5f6d'), fire * skyEnv * 0.35));
// Disk: overexposed (near white) high up, true colour near the horizon.
const core = rgbToHex(lerpRgb(sunRgb, [255, 255, 255], 0.15 + 0.45 * (1 - lowSun)));
return { core, glow, sun: sunHex, diskAlpha: 1 - 0.8 * block, skyGlow, groundGlow, haze };
}
// -------------------------------------------------------------------
// GRASS FILL - ground-strip colour keyed to solar elevation.
// -------------------------------------------------------------------