Profile memory
Comment cleanup
This commit is contained in:
fraxle
2026-05-18 23:02:41 +01:00
parent dded468bec
commit 85c415d1f5
16 changed files with 504 additions and 581 deletions
+38 -115
View File
@@ -261,13 +261,20 @@ export function UTCIForecast() {
// (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.) // (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.)
// Current active filter profile // Current active filter profile - persisted in localStorage
const [activeProfile, setActiveProfile] = useState('basic'); const [activeProfile, setActiveProfile] = useState(() => {
try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
});
// Which columns appear in the hourly table by default. // Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see) // true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on) // false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols }); const [visibleCols, setVisibleCols] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_profile') || 'basic';
return { ...(FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols) };
} catch (e) { return { ...FILTER_PROFILES.basic.cols }; }
});
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] })); const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair. // Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
@@ -276,8 +283,14 @@ export function UTCIForecast() {
// Vehicle type for the cabin heat column. 'car' is the default preset. // Vehicle type for the cabin heat column. 'car' is the default preset.
const [vehicleType, setVehicleType] = useState('car'); const [vehicleType, setVehicleType] = useState('car');
// Places profile sub-variant (urban / beach / events / festival / wintersports / naturist) // Places profile sub-variant - persisted in localStorage
const [outdoorsVariant, setOutdoorsVariant] = useState('urban'); const [outdoorsVariant, setOutdoorsVariant] = useState(() => {
try { return localStorage.getItem('sunscope_outdoors_variant') || 'urban'; } catch (e) { return 'urban'; }
});
const setOutdoorsVariantAndSave = (v) => {
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
setOutdoorsVariant(v);
};
// Vehicle ventilation — true = windows open (high convective loss). // Vehicle ventilation — true = windows open (high convective loss).
const [vehicleVent, setVehicleVent] = useState(false); const [vehicleVent, setVehicleVent] = useState(false);
@@ -287,7 +300,13 @@ export function UTCIForecast() {
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged). // 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
const [buildingType, setBuildingType] = useState('brick'); const [buildingType, setBuildingType] = useState('brick');
const [indoorManaged, setIndoorManaged] = useState(false); const [indoorManaged, setIndoorManaged] = useState(false);
const [indoorMode, setIndoorMode] = useState('off'); const [indoorMode, setIndoorMode] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_profile') || 'basic';
const cols = FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols;
return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off';
} catch (e) { return 'off'; }
});
// Pollen type for the pollen column. Persisted in localStorage. // Pollen type for the pollen column. Persisted in localStorage.
const [pollenType, setPollenType] = useState(() => { const [pollenType, setPollenType] = useState(() => {
@@ -304,6 +323,7 @@ export function UTCIForecast() {
const activateProfile = (key) => { const activateProfile = (key) => {
const profile = FILTER_PROFILES[key]; const profile = FILTER_PROFILES[key];
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
setActiveProfile(key); setActiveProfile(key);
if (key !== 'custom') { if (key !== 'custom') {
setVisibleCols({ ...profile.cols }); setVisibleCols({ ...profile.cols });
@@ -544,7 +564,7 @@ export function UTCIForecast() {
</nav> </nav>
<main class="utci-shell"> <main class="utci-shell">
<!-- ── EVENT BANNER ── slideshow when multiple events active ── -->
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}> <div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
${activeEvents.length > 0 && (() => { ${activeEvents.length > 0 && (() => {
const ev = activeEvents[bannerIndex] || activeEvents[0]; const ev = activeEvents[bannerIndex] || activeEvents[0];
@@ -626,24 +646,7 @@ export function UTCIForecast() {
glob=${currentRow?.glob ?? 0} glob=${currentRow?.glob ?? 0}
activeEvent=${lensEvent} activeEvent=${lensEvent}
/> />
{/* ── FUTURE FEATURE v2: Historical Context Line ─────────────────
Show a subtle single line directly below the dial readout:
e.g. "3.2°C above the May average for this location"
"Near normal for late August"
Design notes:
• Must be visually elegant — same Fraunces/Manrope type pairing
as the rest of the dial area, small and muted
• Positive delta: warm amber tone; negative delta: cool blue tone
• Source: Open-Meteo has a free /climate endpoint that returns
monthly climate normals (ERA5 reanalysis) for any lat/lon.
Fetch once on location change, cache in a ref. Compare today's
peak air temp (or UTCI) to that month's normal.
• Could also show min/max historical context for the week:
"Warmest day forecast this week" / "Coolest night since March"
• The fetch is separate from the main forecast — handle its own
loading/error state independently so it doesn't block the UI.
─────────────────────────────────────────────────────────────── */}
</div> </div>
<div class="header-right"> <div class="header-right">
@@ -694,12 +697,7 @@ export function UTCIForecast() {
${forecast && days.length > 0 && html` ${forecast && days.length > 0 && html`
<${Fragment}> <${Fragment}>
<!--
DAY TABS — one button per day, coloured by confidence band.
Days 4+ get 🔒'd when isPro is false. To change the lock
behaviour (e.g. open a paywall modal instead of doing
nothing), edit the onClick handler below.
-->
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}> <div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
<button <button
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`} class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
@@ -803,13 +801,7 @@ export function UTCIForecast() {
</div> </div>
</div> </div>
<!--
PRO UPSELL CARD shown when a locked day is clicked.
Visible only while proPromptDay !== null. To change the
copy or pricing, edit the strings below. The "Notify me"
button is a mailto: link replace with a real signup
form when you have one.
-->
${proPromptDay !== null && days[proPromptDay] && (() => { ${proPromptDay !== null && days[proPromptDay] && (() => {
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z'); const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' }); const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
@@ -1001,13 +993,7 @@ export function UTCIForecast() {
</div>`; </div>`;
})()} })()}
<!--
FILTER PROFILE SELECTOR presets shown to all users.
Extra profiles are shown in place with a gentle 🔒 and clicking
them triggers the same upsell prompt as locked days.
The bottom border is removed only when the col-toggles bar
follows (Pro users), so the two bars merge into one panel.
-->
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}> <div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
<span class="filter-profiles-label">Profile:</span> <span class="filter-profiles-label">Profile:</span>
${profileButtonOrder.slice(0, 3).map((key) => { ${profileButtonOrder.slice(0, 3).map((key) => {
@@ -1048,8 +1034,9 @@ export function UTCIForecast() {
setProPromptDay(0); setProPromptDay(0);
return; return;
} }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors'); setActiveProfile('outdoors');
setOutdoorsVariant(v); setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols }); setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off'); setIndoorMode('off');
setIndoorManaged(false); setIndoorManaged(false);
@@ -1077,8 +1064,9 @@ export function UTCIForecast() {
setProPromptDay(0); setProPromptDay(0);
return; return;
} }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors'); setActiveProfile('outdoors');
setOutdoorsVariant(v); setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols }); setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off'); setIndoorMode('off');
setIndoorManaged(false); setIndoorManaged(false);
@@ -1108,12 +1096,7 @@ export function UTCIForecast() {
</div> </div>
<!--
COLUMN TOGGLES in exact table column order.
Buttons visible to all users when profile includes that col.
Burn + Vehicle dropdowns always shown (free + pro).
Pro users see all toggles; free users see profile-filtered subset.
-->
${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html` ${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
<div class="col-toggles"> <div class="col-toggles">
<span class="col-toggles-label">Columns:</span> <span class="col-toggles-label">Columns:</span>
@@ -1244,68 +1227,13 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</div>`} </div>`}
<!--
HOURLY TABLE each row is one hour from the selected day.
Each column is wrapped in a visibleCols.X check, so it only
shows when its toggle is on. To force a column to always
show, remove the visibleCols check around it. To rename a
heading, edit the text inside the matching <th>.
-->
{/* ── FUTURE FEATURE v2: Sticky "Now" Row + Resizable Table ────────
TWO related ideas for the table UX:
1. AUTO-SCROLL TO NOW ON LOAD
On first render (or day change to today), scroll the tbody
so the current-hour row is visible — ideally centred or at
the top third of the viewport. Use a ref on the now-row <tr>
and call scrollIntoView({ block: 'center', behavior: 'smooth' })
inside a useEffect that fires when days[selectedDay] changes.
The now row already has `currentRow` identified — just need
to attach a ref and fire the scroll.
2. VERTICALLY RESIZABLE TABLE (extends existing drag-to-scroll)
The table already has horizontal drag-to-scroll (useTableScroll).
For v2, make the tbody height resizable — a drag handle on the
bottom edge of the table, similar to how devtools panels resize.
Store the preferred height in localStorage ('sunscope_table_height').
Combined with the now-row auto-scroll, the user sets the table to
exactly the number of rows they want to see at once and it always
opens at the current hour. Very clean UX.
────────────────────────────────────────────────────────────────── */}
{/* ── FUTURE FEATURE v2: Mobile Card Layout ─────────────────────────
On small screens the wide table is painful. Consider a responsive
breakpoint (e.g. < 640px) that switches from the table to a
vertical stack of hour cards:
┌──────────────────────────────────┐
│ 14:00 🌤️ SkyScope porthole │
│ Air 24°C Wind 12 km/h SW │
│ UTCI+P ████░░░░ 28.4°C Warm │
│ UV 6 · Burn 35 min · Precip
└──────────────────────────────────┘
Each card shows only the columns that are active in the current
profile — same visibility logic, different layout. The card design
should stay true to the brass/parchment aesthetic.
Implementation approach:
• CSS media query switches .utci-table-wrap to display:none
and shows a .utci-card-list instead (same data, different markup)
• OR: render the cards in JS from the same hourlyRows array,
conditionally based on a useWindowWidth() hook
• Needs careful thought on which columns to show in "summary"
view vs an expandable "detail" tap — don't want to overwhelm
the card but also don't want to hide too much.
• General spacing and layout passes needed for mobile regardless
of whether the card view is implemented in v2.
────────────────────────────────────────────────────────────────── */}
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}`} ref=${tableWrapRef}> <div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}`} ref=${tableWrapRef}>
<span class="utci-scroll-chevron left" aria-hidden="true"></span> <span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span> <span class="utci-scroll-chevron right" aria-hidden="true"></span>
<!-- Sticky header strip locks to viewport top. Clipped
horizontally; the inner .utci-thead-track is shifted
via translateX from JS to follow the body's scrollLeft.
See handleBodyScroll + useLayoutEffect above. -->
<div class="utci-thead-sticky" ref=${headStickyRef}> <div class="utci-thead-sticky" ref=${headStickyRef}>
<div class="utci-thead-track" ref=${headTrackRef}> <div class="utci-thead-track" ref=${headTrackRef}>
<table class="utci-table utci-table-head" ref=${headTableRef}> <table class="utci-table utci-table-head" ref=${headTableRef}>
@@ -1344,9 +1272,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</table> </table>
</div> </div>
</div> </div>
<!-- Body scroller — owns the horizontal scrollbar. The
onScroll handler translates the header track to keep
columns aligned with the visible body columns. -->
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}> <div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-body" ref=${bodyTableRef}> <table class="utci-table utci-table-body" ref=${bodyTableRef}>
<tbody> <tbody>
@@ -1655,10 +1581,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</div> </div>
</div> </div>
<!--
ALMANAC — upcoming cosmic events in the next 3 months.
Location-aware: visibility notes adjust by latitude.
-->
${(() => { ${(() => {
const upcoming = getUpcomingEvents(location, 90); const upcoming = getUpcomingEvents(location, 90);
const formatPeak = (iso) => { const formatPeak = (iso) => {
+83 -83
View File
@@ -1,7 +1,7 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// components.js Preact UI components (SVG widgets and controls). // components.js - Preact UI components (SVG widgets and controls).
// //
// All components use the html`` tagged template from htm + Preact. // All components use the html'' tagged template from htm + Preact.
// No external state; they depend only on utils.js and events.js. // No external state; they depend only on utils.js and events.js.
// //
// Exports (in order of appearance): // Exports (in order of appearance):
@@ -13,7 +13,7 @@
// ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent }) // ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent })
// large UTCI dial // large UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon // PrecipIcon({ precip, snow, size }) rain/snow icon
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { h, Fragment } from '../vendor/preact.js'; import { h, Fragment } from '../vendor/preact.js';
import { useState, useEffect, useRef } from '../vendor/preact-hooks.js'; import { useState, useEffect, useRef } from '../vendor/preact-hooks.js';
@@ -26,19 +26,19 @@ import { getLensOverlaySVG } from './events.js';
const html = htm.bind(h); const html = htm.bind(h);
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// CUSTOMSELECT cross-platform styled dropdown pill. // CUSTOMSELECT - cross-platform styled dropdown pill.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// props: // props:
// value current value string // value - current value string
// options array of { value, label, disabled } // options - array of { value, label, disabled }
// onChange fn(newValue) // onChange - fn(newValue)
// hideLabel label shown when value === 'off' (e.g. 'Burn') // hideLabel - label shown when value === 'off' (e.g. 'Burn')
// hidingLabel label shown for the 'off' option when active (e.g. 'Hide Burn') // hidingLabel - label shown for the 'off' option when active (e.g. 'Hide Burn')
// isOn bool whether the pill shows as active (brass) // isOn - bool - whether the pill shows as active (brass)
// groupedLeft bool fuse right side with a VentPill // groupedLeft - bool - fuse right side with a VentPill
// isLastChild bool restore right border-radius when no sibling follows // isLastChild - bool - restore right border-radius when no sibling follows
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild }) { export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const wrapRef = useRef(null); const wrapRef = useRef(null);
@@ -111,15 +111,15 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
</span>`; </span>`;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// VENTPILL the checkbox pill fused to the right of a CustomSelect. // VENTPILL - the checkbox pill fused to the right of a CustomSelect.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// props: // props:
// checked bool // checked - bool
// onChange fn() // onChange - fn()
// label string // label - string
// title tooltip string // title - tooltip string
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function VentPill({ checked, onChange, label, title }) { export function VentPill({ checked, onChange, label, title }) {
return html` return html`
<label class=${`cs-vent${checked ? ' on' : ''}`} title=${title}> <label class=${`cs-vent${checked ? ' on' : ''}`} title=${title}>
@@ -134,23 +134,23 @@ export function VentPill({ checked, onChange, label, title }) {
</label>`; </label>`;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// SKYSCOPE the little porthole circle next to each hour. // SKYSCOPE - the little porthole circle next to each hour.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// What gets drawn (back to front): // What gets drawn (back to front):
// 1. Sky disk colour from skyFillForElev() above // 1. Sky disk - colour from skyFillForElev() above
// 2. Horizon line faint dashed line at the middle // 2. Horizon line - faint dashed line at the middle
// 3. Sun OR moon sun positioned vertically by elevation; // 3. Sun OR moon - sun positioned vertically by elevation;
// moon shows real phase (synodic period) // moon shows real phase (synodic period)
// 4. Brass ring the telescope/scope edge (#c8922a) // 4. Brass ring - the telescope/scope edge (#c8922a)
// 5. Lens highlight subtle inner glint // 5. Lens highlight - subtle inner glint
// //
// Tweakable bits inside this function: // Tweakable bits inside this function:
// size pass a different size= when calling for bigger/smaller // - size - pass a different size= when calling for bigger/smaller
// innerR how thick the brass ring looks // - innerR - how thick the brass ring looks
// sunR the sun's drawn radius // - sunR - the sun's drawn radius
// the stroke colour #c8922a is the brass change for a different metal // - 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 }) {
const r = size / 2; const r = size / 2;
const innerR = r - 1.2; const innerR = r - 1.2;
@@ -165,14 +165,14 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const isTwil = elev > -8 && !isDay; // moon shown in twilight too const isTwil = elev > -8 && !isDay; // moon shown in twilight too
const isNight = elev <= -8; const isNight = elev <= -8;
// Sun position clamped so it never goes below the horizon line (y = r) // Sun position - clamped so it never goes below the horizon line (y = r)
const elevClamped = Math.max(0, Math.min(90, elev)); const elevClamped = Math.max(0, Math.min(90, elev));
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5); const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
const sunR = Math.max(2.2, innerR * 0.32); const sunR = Math.max(2.2, innerR * 0.32);
// Radial glow: strength driven by glob (W/m²). Uses a radialGradient // Radial glow: strength driven by glob (W/m-). Uses a radialGradient
// so it's intense at the sun centre and fades smoothly to transparent. // 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 radFrac = Math.min(1, (glob || 0) / 900); // 0 (dark) - 1 (blazing)
const horizFrac = Math.max(0, 1 - elev / 20); // extra warmth near horizon const horizFrac = Math.max(0, 1 - elev / 20); // extra warmth near horizon
const glowR = sunR + 4 + radFrac * 6; // total glow radius const glowR = sunR + 4 + radFrac * 6; // total glow radius
const glowPeak = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity const glowPeak = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
@@ -253,20 +253,20 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
</svg>`; </svg>`;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// WINDVANE clean compass arrow on transparent background. // WINDVANE - clean compass arrow on transparent background.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Traditional weather-vane behaviour: the ARROWHEAD points INTO the // Traditional weather-vane behaviour: the ARROWHEAD points INTO the
// wind (toward the source). Wind from the north head points north. // wind (toward the source). Wind from the north - head points north.
// //
// props: // props:
// bearing degrees, 0 = wind FROM north // bearing - degrees, 0 = wind FROM north
// size pixel diameter (default 30 to match SkyScope) // size - pixel diameter (default 30 to match SkyScope)
// //
// Tweakable bits: // Tweakable bits:
// arrowColor / nMarkerColor line/fill colours // - arrowColor / nMarkerColor - line/fill colours
// Tiny N letter sits just above the arrow tail for orientation // - Tiny N letter sits just above the arrow tail for orientation
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function WindVane({ bearing, size = 30 }) { export function WindVane({ bearing, size = 30 }) {
if (bearing == null || isNaN(bearing)) { if (bearing == null || isNaN(bearing)) {
return html`<span style=${{ opacity: 0.5, fontSize: '11px' }}>—</span>`; return html`<span style=${{ opacity: 0.5, fontSize: '11px' }}>—</span>`;
@@ -280,7 +280,7 @@ export function WindVane({ bearing, size = 30 }) {
const nColor = '#9a7d5a'; const nColor = '#9a7d5a';
// Geometry of the single-ended arrow (drawn pointing DOWN by default). // Geometry of the single-ended arrow (drawn pointing DOWN by default).
// After rotation by (bearing + 180), the head lands on the bearing // After rotation by (bearing + 180), the head lands on the bearing
// direction i.e. the side the wind is coming FROM. // direction - i.e. the side the wind is coming FROM.
const tipY = r * 0.15; // arrowhead tip const tipY = r * 0.15; // arrowhead tip
const headBase = r * 0.58; // bottom of the triangle head const headBase = r * 0.58; // bottom of the triangle head
const headW = r * 0.42; const headW = r * 0.42;
@@ -311,14 +311,14 @@ export function WindVane({ bearing, size = 30 }) {
</svg>`; </svg>`;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// CLOUDICON Brass Line cloud / haze / overcast glyphs. // CLOUDICON - Brass Line cloud / haze / overcast glyphs.
// No sun or moon here; the time-cell SkyScope owns day/night imagery. // No sun or moon here; the time-cell SkyScope owns day/night imagery.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// category: 'clear' | 'wispy' | 'scattered' | 'overcast' // category: 'clear' | 'wispy' | 'scattered' | 'overcast'
// elev: solar elevation in degrees (negative = night) // elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase // dt: Date used for moon phase
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) { export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
const r = size / 2; const r = size / 2;
const brass = '#c8922a'; const brass = '#c8922a';
@@ -345,7 +345,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
${line(size * 0.30, y0 + size * 0.18, size * 0.92, y0 + size * 0.18, ink, sw * 0.82, 0.72)} ${line(size * 0.30, y0 + size * 0.18, size * 0.92, y0 + size * 0.18, ink, sw * 0.82, 0.72)}
</g>`; </g>`;
// Sun shape circle + 8 rays, centred at (cx, cy) // Sun shape - circle + 8 rays, centred at (cx, cy)
const sun = (cx, cy, cr) => { const sun = (cx, cy, cr) => {
const rays = Array.from({ length: 8 }, (_, k) => { const rays = Array.from({ length: 8 }, (_, k) => {
const angle = (k * Math.PI) / 4; const angle = (k * Math.PI) / 4;
@@ -382,23 +382,23 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
</svg>`; </svg>`;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// SCOPERETICLE the big circular UTCI dial in the page header. // SCOPERETICLE - the big circular UTCI dial in the page header.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// This is the one with tick marks, the swept colour band, and the // This is the one with tick marks, the swept colour band, and the
// needle pointing at the current felt-temperature. // needle pointing at the current felt-temperature.
// //
// Tweakable bits: // Tweakable bits:
// R = 86 outer ring radius (changes overall size) // - R = 86 outer ring radius (changes overall size)
// cx, cy = 100 centre point (leave alone unless you also // - cx, cy = 100 centre point (leave alone unless you also
// change the viewBox="0 0 200 200" below) // change the viewBox="0 0 200 200" below)
// { length: 36 } number of tick marks (1 every 10°) // - { length: 36 } number of tick marks (1 every 10-)
// stressBands[] the colour ramp around the rim (matches UTCI) // - stressBands[] the colour ramp around the rim (matches UTCI)
// (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep // - (value + 10) / 60 maps UTCI -10..50 onto the 270- sweep -
// widen the dial range by changing those numbers // widen the dial range by changing those numbers
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0, activeEvent = null }) { export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0, activeEvent = null }) {
// ── Day/night scene behind the readout ───────────────────────────── // -- Day/night scene behind the readout -----------------------------
// The whole back of the lens is a SkyScope-style scene: sky on top, // 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 // grass on the bottom, sun positioned by elevation (or moon at night
// with its phase shadow). Mirrors the little hourly SkyScopes. // with its phase shadow). Mirrors the little hourly SkyScopes.
@@ -415,7 +415,7 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon 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 sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
const sunDrawR = 14; const sunDrawR = 14;
// Radial glow for the big dial sun is always at least faintly glowing // Radial glow for the big dial - sun is always at least faintly glowing
// when above the horizon; scales up with radiation strength. // when above the horizon; scales up with radiation strength.
const radFrac = Math.min(1, (glob || 0) / 900); const radFrac = Math.min(1, (glob || 0) / 900);
const horizFrac = Math.max(0, 1 - elev / 20); const horizFrac = Math.max(0, 1 - elev / 20);
@@ -628,22 +628,22 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
</svg>`; </svg>`;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// PRECIPICON rain drop or snowflake SVG for the precip cell. // PRECIPICON - rain drop or snowflake SVG for the precip cell.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// precip mm/h rainfall // precip - mm/h rainfall
// snow cm/h snowfall // snow - cm/h snowfall
// size pixel size (default 28 to match CloudIcon) // size - pixel size (default 28 to match CloudIcon)
// //
// Intensity bands: // Intensity bands:
// dry faint dash // dry - faint dash
// light rain (< 1 mm/h) 1 drop // light rain (< 1 mm/h) - 1 drop
// moderate rain(< 4 mm/h) 2 drops // moderate rain(< 4 mm/h) - 2 drops
// heavy rain ( 4 mm/h) 3 drops // heavy rain (- 4 mm/h) - 3 drops
// light snow (< 0.5cm/h) 1 flake // light snow (< 0.5cm/h) - 1 flake
// heavy snow ( 0.5cm/h) 2 flakes // heavy snow (- 0.5cm/h) - 2 flakes
// mixed 1 drop + 1 flake // mixed - 1 drop + 1 flake
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) { export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
const r = size / 2; const r = size / 2;
const hasRain = precip > 0; const hasRain = precip > 0;
@@ -673,7 +673,7 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
<line x1=${cx + fr * 0.68} y1=${cy - fr * 0.68} x2=${cx - fr * 0.68} y2=${cy + fr * 0.68} /> <line x1=${cx + fr * 0.68} y1=${cy - fr * 0.68} x2=${cx - fr * 0.68} y2=${cy + fr * 0.68} />
</g>`; </g>`;
// Dry just a faint dash // Dry - just a faint dash
if (!hasRain && !hasSnow) { if (!hasRain && !hasSnow) {
return html` return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`} <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
@@ -683,9 +683,9 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
</svg>`; </svg>`;
} }
// Rain intensity number of drops // Rain intensity - number of drops
const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3; const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3;
// Snow intensity number of flakes // Snow intensity - number of flakes
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2; const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
const fr = size * 0.08; // flake arm length const fr = size * 0.08; // flake arm length
+47 -47
View File
@@ -1,14 +1,14 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// compute.js Build the per-hour display rows from the raw API data. // compute.js - Build the per-hour display rows from the raw API data.
// //
// Pure-ish function: feed in (forecast, airQuality, location, vehicleType, // Pure-ish function: feed in (forecast, airQuality, location, vehicleType,
// vehicleVent, buildingType) and get back { hourlyRows, days, utcOffsetMs }. // vehicleVent, buildingType) and get back { hourlyRows, days, utcOffsetMs }.
// //
// Open-Meteo with timezone=auto returns local wall-clock strings like // Open-Meteo with timezone=auto returns local wall-clock strings like
// "2026-05-13T14:00" no Z suffix. Two forms are used in each row: // "2026-05-13T14:00" - no Z suffix. Two forms are used in each row:
// String slices (iso.slice(...)) for display & day grouping // - String slices (iso.slice(...)) for display & day grouping
// A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*). // - A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*).
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { import {
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
@@ -20,7 +20,7 @@ import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js'
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType }) { export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType }) {
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;
// Build a fast lookup map from the air quality hourly data: ISO string index. // Build a fast lookup map from the air quality hourly data: ISO string - index.
// Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like // Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like
// "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14". // "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
const aqTimeMap = {}; const aqTimeMap = {};
@@ -56,17 +56,17 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null;
const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null;
// Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete. // Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete.
// direct_radiation is zero on fully overcast days far more accurate than // direct_radiation is zero on fully overcast days - far more accurate than
// shortwave_radiation which can be unreliably high even at 100% cloud cover. // shortwave_radiation which can be unreliably high even at 100% cloud cover.
// Diffuse (scattered light through cloud) contributes ~20% as much heat to // Diffuse (scattered light through cloud) contributes ~20% as much heat to
// a surface as direct beam, so we weight it accordingly. // a surface as direct beam, so we weight it accordingly.
const effectiveRad = dir + dif * 0.2; const effectiveRad = dir + dif * 0.2;
const concreteT = calcConcreteTemp(Ta, effectiveRad, va); const concreteT = calcConcreteTemp(Ta, effectiveRad, va);
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
// For display we slice the string directly no Date object needed. // For display we slice the string directly - no Date object needed.
// For solarElevationDeg (which uses .getUTC* internally) we need the // For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset. // true UTC instant: treat the local time as UTC then subtract the offset.
// e.g. Brisbane UTC+10: local 14:00 parse as UTC 14:00 subtract 10h UTC 04:00 // e.g. Brisbane UTC+10: local 14:00 - parse as UTC 14:00 - subtract 10h - UTC 04:00 -
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs); const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
const elev = solarElevationDeg(location.lat, location.lon, dt); const elev = solarElevationDeg(location.lat, location.lon, dt);
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
@@ -78,7 +78,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const compass = windCompass8(wd); const compass = windCompass8(wd);
const { uvA, uvB } = uvSplit(uv, elev); const { uvA, uvB } = uvSplit(uv, elev);
const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh);
// Visibility from the main forecast API (metres km). // Visibility from the main forecast API (metres - km).
const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })(); const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })();
const aqi = getAq('european_aqi', iso); const aqi = getAq('european_aqi', iso);
const grassPollen = getAq('grass_pollen', iso); const grassPollen = getAq('grass_pollen', iso);
@@ -87,22 +87,22 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const mugwortPollen= getAq('mugwort_pollen', iso); const mugwortPollen= getAq('mugwort_pollen', iso);
const olivePollen = getAq('olive_pollen', iso); const olivePollen = getAq('olive_pollen', iso);
const ragweedPollen= getAq('ragweed_pollen', iso); const ragweedPollen= getAq('ragweed_pollen', iso);
// ── FUTURE FEATURE: Activity "What If" Modifier ─────────────────────────── // -- FUTURE FEATURE: Activity "What If" Modifier ---------------------------
// Add two extra columns driven by a user-selected activity level. These are // Add two extra columns driven by a user-selected activity level. These are
// intentionally kept SEPARATE from the core columns above so that baseline // intentionally kept SEPARATE from the core columns above so that baseline
// profile data stays consistent and comparable across profiles. // profile data stays consistent and comparable across profiles.
// //
// The user picks an activity from a simple UI picker (no live data needed // The user picks an activity from a simple UI picker (no live data needed -
// this is a forecast/planning tool, not a tracker): // this is a forecast/planning tool, not a tracker):
// Resting Walking Cycling Running Sport/Intense // Resting - Walking - Cycling - Running - Sport/Intense
// //
// Two output columns only (keep it clean): // Two output columns only (keep it clean):
// //
// adjustedSafeTime baseline UV safe exposure time × an activity multiplier. // adjustedSafeTime - baseline UV safe exposure time - an activity multiplier.
// Higher activity = shorter safe time, because: // Higher activity = shorter safe time, because:
// metabolic heat raises core body temp // - metabolic heat raises core body temp
// sweating washes away sunscreen faster // - sweating washes away sunscreen faster
// more skin blood flow = higher UV sensitivity // - more skin blood flow = higher UV sensitivity
// Suggested multipliers (tune with real data): // Suggested multipliers (tune with real data):
// Resting: 1.0 (no change) // Resting: 1.0 (no change)
// Walking: 0.85 // Walking: 0.85
@@ -110,22 +110,22 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// Running: 0.60 // Running: 0.60
// Sport: 0.50 // Sport: 0.50
// //
// heatStressLevel a simple label: 'Low' | 'Moderate' | 'High' | 'Very High' // heatStressLevel - a simple label: 'Low' | 'Moderate' | 'High' | 'Very High'
// Derived from UTCI + activity heat load. A runner at // Derived from UTCI + activity heat load. A runner at
// UTCI 28°C should read 'High' even if a resting person // UTCI 28-C should read 'High' even if a resting person
// would read 'Moderate' at the same UTCI. // would read 'Moderate' at the same UTCI.
// Colour code in the UI: 🟢 🟡 🟠 🔴 // Colour code in the UI: - - - -
// //
// Implementation sketch: // Implementation sketch:
// 1. Accept `activityLevel` as a new param to buildHourlyRows() alongside // 1. Accept 'activityLevel' as a new param to buildHourlyRows() alongside
// vehicleType, buildingType etc. // vehicleType, buildingType etc.
// 2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level). // 2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level).
// 3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier // 3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier
// 4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels. // 4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels.
// 5. Add both fields to the returned row object below. // 5. Add both fields to the returned row object below.
// 6. In components.js, render these as optional columns that only appear when // 6. In components.js, render these as optional columns that only appear when
// an activity other than 'Resting' is selected keeps the default table clean. // an activity other than 'Resting' is selected - keeps the default table clean.
// ───────────────────────────────────────────────────────────────────────────── // -----------------------------------------------------------------------------
return { return {
iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
@@ -160,45 +160,45 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
day.rows.push(row); day.rows.push(row);
}); });
// ── FUTURE FEATURE v2: Today Summary + Alert System ────────────────────────── // -- FUTURE FEATURE v2: Today Summary + Alert System --------------------------
// After grouping rows into days, generate a per-day summary object that powers // After grouping rows into days, generate a per-day summary object that powers
// a stylish "Today at a Glance" panel shown above or below the main dial. // a stylish "Today at a Glance" panel shown above or below the main dial.
// //
// The summary is NOT a live alert/push system it's a forecast digest that // The summary is NOT a live alert/push system - it's a forecast digest that
// refreshes with the forecast data. Think of it as a smart briefing card. // refreshes with the forecast data. Think of it as a smart briefing card.
// //
// WHAT TO COMPUTE (per day, from that day's rows): // WHAT TO COMPUTE (per day, from that day's rows):
// Peak UTCI+P and time it occurs heat stress headline // - Peak UTCI+P and time it occurs - heat stress headline
// Min UTCI+P and time cold stress headline // - Min UTCI+P and time - cold stress headline
// Peak UV index and time UV warning // - Peak UV index and time - UV warning
// Max precipitation rate and time rain/ice warning // - Max precipitation rate and time - rain/ice warning
// Max vehicle cabin temp "dangerous to leave pets/children in car" // - Max vehicle cabin temp - "dangerous to leave pets/children in car"
// Max pollen level + type pollen advisory // - Max pollen level + type - pollen advisory
// Road condition risk (low air temp + precip ice risk) // - Road condition risk (low air temp + precip - ice risk)
// //
// ALERT CATEGORIES (each generates a styled warning card if threshold exceeded): // ALERT CATEGORIES (each generates a styled warning card if threshold exceeded):
// 🌡️ Heat warning UTCI+P > 32°C // -- Heat warning UTCI+P > 32-C
// 🥶 Cold warning UTCI+P < 0°C // - Cold warning UTCI+P < 0-C
// ☀️ UV warning UV index > 6 // -- UV warning UV index > 6
// 🌧️ Heavy rain precip > 4mm/h // -- Heavy rain precip > 4mm/h
// 🧊 Ice/road risk Ta < 3°C + any precip (or recent precip overnight) // - Ice/road risk Ta < 3-C + any precip (or recent precip overnight)
// 🚗 Vehicle danger vehicleT > 35°C ("don't leave pets or children in car") // - Vehicle danger vehicleT > 35-C ("don't leave pets or children in car")
// 🌿 High pollen any pollen type > 50 grains/m³ // - High pollen any pollen type > 50 grains/m-
// //
// DESIGN NOTES: // DESIGN NOTES:
// Cards should be concise one line of bold text + a short explanation // - Cards should be concise - one line of bold text + a short explanation
// Colour-coded to match the existing UTCI stress band palette // - Colour-coded to match the existing UTCI stress band palette
// Collapsible show top 2-3 alerts by default, expand for full list // - Collapsible - show top 2-3 alerts by default, expand for full list
// For today only (days[0]); optionally extend to day tabs in a later pass // - For today only (days[0]); optionally extend to day tabs in a later pass
// The "X°C above seasonal norm" historical context line (see app.js comment) // - The "X-C above seasonal norm" historical context line (see app.js comment)
// could live here too, as a subtle subheading under the dial temperature // could live here too, as a subtle subheading under the dial temperature
// //
// Suggested return shape add to the return value below: // Suggested return shape - add to the return value below:
// daySummaries: days.map(day => buildDaySummary(day.rows)) // daySummaries: days.map(day => buildDaySummary(day.rows))
// //
// where buildDaySummary() is a new helper in this file (or a separate // where buildDaySummary() is a new helper in this file (or a separate
// summary.js module if it grows large). // summary.js module if it grows large).
// ───────────────────────────────────────────────────────────────────────────── // -----------------------------------------------------------------------------
return { hourlyRows, days, utcOffsetMs }; return { hourlyRows, days, utcOffsetMs };
} }
+21 -21
View File
@@ -1,8 +1,8 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// config.js Pure-data configuration constants for the UTCIForecast app. // config.js - Pure-data configuration constants for the UTCIForecast app.
// //
// Moved out of app.js so the main component is easier to read and edit. // Moved out of app.js so the main component is easier to read and edit.
// Nothing here has state or side effects just constants and lookup // Nothing here has state or side effects - just constants and lookup
// tables imported by app.js (and anywhere else that needs them). // tables imported by app.js (and anywhere else that needs them).
// //
// Where to find things: // Where to find things:
@@ -15,13 +15,13 @@
// activityVariantKeys ........ which variants appear in the Activities menu // activityVariantKeys ........ which variants appear in the Activities menu
// placeVariantKeys ........... which variants appear in the Places menu // placeVariantKeys ........... which variants appear in the Places menu
// COL_DESCRIPTIONS ........... tooltip text for each table column header // COL_DESCRIPTIONS ........... tooltip text for each table column header
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// How many days the free tier shows. Days beyond this get a 🔒. // How many days the free tier shows. Days beyond this get a -.
// Bump this number if you want to give free users more access. // Bump this number if you want to give free users more access.
export const FREE_DAYS = 3; export const FREE_DAYS = 3;
// Filter profile presets each preset defines which columns are visible // Filter profile presets - each preset defines which columns are visible
// when that profile is selected. // when that profile is selected.
export const FILTER_PROFILES = { export const FILTER_PROFILES = {
basic: { basic: {
@@ -61,37 +61,37 @@ export const FILTER_PROFILES = {
icon: '⚙️', icon: '⚙️',
proOnly: true, proOnly: true,
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false }, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false },
// ── FUTURE FEATURE v2: Saved Custom Profiles (Pro) ─────────────────────── // -- FUTURE FEATURE v2: Saved Custom Profiles (Pro) -----------------------
// Expand 'Custom' into a fully user-owned profile system. Pro users can: // Expand 'Custom' into a fully user-owned profile system. Pro users can:
// //
// CREATE named profiles give them a name and icon, choose any columns // - CREATE named profiles - give them a name and icon, choose any columns
// SAVE multiple profiles stored in localStorage (or user account if we // - SAVE multiple profiles - stored in localStorage (or user account if we
// add auth), each appearing as an extra pill in the profile button bar // add auth), each appearing as an extra pill in the profile button bar
// REORDER columns drag-and-drop within the custom profile editor. // - REORDER columns - drag-and-drop within the custom profile editor.
// Column order is just an array of keys; shuffle the array, re-render. // Column order is just an array of keys; shuffle the array, re-render.
// The existing col visibility flags stay, reordering is a separate // The existing col visibility flags stay, reordering is a separate
// `colOrder: ['hour', 'air', 'wind', ...]` array on the profile object. // 'colOrder: ['hour', 'air', 'wind', ...]' array on the profile object.
// DELETE / RENAME profiles in a small management modal // - DELETE / RENAME profiles in a small management modal
// //
// The 'Custom' button becomes " New Profile" when in profile-creation mode. // The 'Custom' button becomes "- New Profile" when in profile-creation mode.
// Existing preset profiles (Basic, Home, Vehicle) remain read-only. // Existing preset profiles (Basic, Home, Vehicle-) remain read-only.
// //
// Data shape per saved profile: // Data shape per saved profile:
// { // {
// id: 'profile_abc123', // generated uuid // id: 'profile_abc123', // generated uuid
// label: 'My Beach Days', // label: 'My Beach Days',
// icon: '🏖️', // user picks from a small emoji set // icon: '--', // user picks from a small emoji set
// cols: { ...standard col flags }, // cols: { ...standard col flags },
// colOrder: ['hour', 'air', 'burn', 'wind', ...], // user's preferred order // colOrder: ['hour', 'air', 'burn', 'wind', ...], // user's preferred order
// createdAt: ISO string, // createdAt: ISO string,
// } // }
// //
// Storage: JSON.stringify array in localStorage under 'sunscope_custom_profiles'. // Storage: JSON.stringify array in localStorage under 'sunscope_custom_profiles'.
// ───────────────────────────────────────────────────────────────────────────── // -----------------------------------------------------------------------------
}, },
}; };
// Places sub-variants each has its own column set. // Places sub-variants - each has its own column set.
// Selecting a variant applies its cols to visibleCols. // Selecting a variant applies its cols to visibleCols.
export const OUTDOORS_VARIANTS = { export const OUTDOORS_VARIANTS = {
urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } }, urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } },
@@ -101,16 +101,16 @@ export const OUTDOORS_VARIANTS = {
wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } }, naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
// Places free // Places - free
park: { name: 'Park / Picnic', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, park: { name: 'Park / Picnic', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
airport: { name: 'Airport / Travel', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, airport: { name: 'Airport / Travel', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
// Places Pro // Places - Pro
construction: { name: 'Construction', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } }, construction: { name: 'Construction', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } },
// Activities free // Activities - free
cycling: { name: 'Cycling', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, cycling: { name: 'Cycling', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
running: { name: 'Running', cols: { hour: true, air: true, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, running: { name: 'Running', cols: { hour: true, air: true, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
dogwalk: { name: 'Dog Walking', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, dogwalk: { name: 'Dog Walking', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
// Activities Pro // Activities - Pro
hiking: { name: 'Hiking', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, hiking: { name: 'Hiking', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
photography: { name: 'Photography', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, photography: { name: 'Photography', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
fishing: { name: 'Fishing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, fishing: { name: 'Fishing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
+12 -12
View File
@@ -1,12 +1,12 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// events.js Cosmic & weather event engine for SunScope. // events.js - Cosmic & weather event engine for SunScope.
// //
// This is the public-facing barrel module. It composes the smaller files // This is the public-facing barrel module. It composes the smaller files
// under ./events/ and exposes the same API as before, so callers // under ./events/ and exposes the same API as before, so callers
// (app.js, components.js) keep working without changes. // (app.js, components.js) keep working without changes.
// //
// Returns the "active event" (or null) based on: // Returns the "active event" (or null) based on:
// 1. PROMO_OVERRIDE manually set a message for promotions etc. // 1. PROMO_OVERRIDE - manually set a message for promotions etc.
// 2. Hardcoded cosmic calendar (eclipses, meteor showers, alignments) // 2. Hardcoded cosmic calendar (eclipses, meteor showers, alignments)
// 3. Weather-derived events (stargazing, sunset, heat spike, storm) // 3. Weather-derived events (stargazing, sunset, heat spike, storm)
// //
@@ -24,7 +24,7 @@
// Weather check functions ..... ./events/weather-checks.js // Weather check functions ..... ./events/weather-checks.js
// Dynamic message helper ...... ./events/dynamic-message.js // Dynamic message helper ...... ./events/dynamic-message.js
// Lens overlay SVG renderer ... ./events/lens-overlay.js // Lens overlay SVG renderer ... ./events/lens-overlay.js
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { COSMIC_CALENDAR } from './events/cosmic-calendar.js'; import { COSMIC_CALENDAR } from './events/cosmic-calendar.js';
import { import {
@@ -40,16 +40,16 @@ import { dynamicCosmicMessage } from './events/dynamic-message.js';
export { getUpcomingEvents } from './events/almanac-calendar.js'; export { getUpcomingEvents } from './events/almanac-calendar.js';
export { getLensOverlaySVG } from './events/lens-overlay.js'; export { getLensOverlaySVG } from './events/lens-overlay.js';
// ─── PROMO OVERRIDE ────────────────────────────────────────────────────── // --- PROMO OVERRIDE ------------------------------------------------------
// Set this to show a custom banner regardless of weather or cosmic events. // Set this to show a custom banner regardless of weather or cosmic events.
// Leave as null for automatic event detection. // Leave as null for automatic event detection.
// //
// Example: // Example:
// export const PROMO_OVERRIDE = { // export const PROMO_OVERRIDE = {
// id: 'promo-summer', // id: 'promo-summer',
// emoji: '☀️', // emoji: '--',
// title: 'Summer Sale', // title: 'Summer Sale',
// message: 'SunScope Extra 20% off this weekend only.', // message: 'SunScope Extra - 20% off this weekend only.',
// color: '#c8922a', // color: '#c8922a',
// textColor: '#fff', // textColor: '#fff',
// type: 'promo', // type: 'promo',
@@ -57,7 +57,7 @@ export { getLensOverlaySVG } from './events/lens-overlay.js';
// //
export const PROMO_OVERRIDE = null; export const PROMO_OVERRIDE = null;
// ─── CELL TAG LOGIC ────────────────────────────────────────────────────── // --- CELL TAG LOGIC ------------------------------------------------------
// Returns the subset of active events that should show an icon for this row. // Returns the subset of active events that should show an icon for this row.
export function getCellTagEvents(events, row) { export function getCellTagEvents(events, row) {
if (!events || events.length === 0) return []; if (!events || events.length === 0) return [];
@@ -73,7 +73,7 @@ export function getCellTagEvents(events, row) {
}); });
} }
// ─── MAIN EXPORT ───────────────────────────────────────────────────────── // --- MAIN EXPORT ---------------------------------------------------------
// Returns ALL active events for the given rows/date as an array. // Returns ALL active events for the given rows/date as an array.
// Empty array = nothing active. // Empty array = nothing active.
// //
@@ -83,10 +83,10 @@ export function getCellTagEvents(events, row) {
// 3. All matching weather-derived events // 3. All matching weather-derived events
export function getActiveEvents(rows, location) { export function getActiveEvents(rows, location) {
// 1. Manual promo override shown alone, no mixing with other events // 1. Manual promo override - shown alone, no mixing with other events
if (PROMO_OVERRIDE) return [PROMO_OVERRIDE]; if (PROMO_OVERRIDE) return [PROMO_OVERRIDE];
// 2. Cosmic calendar use the date of the rows being viewed, not today. // 2. Cosmic calendar - use the date of the rows being viewed, not today.
const dateStr = (rows && rows.length > 0) const dateStr = (rows && rows.length > 0)
? rows[0].iso.slice(0, 10) ? rows[0].iso.slice(0, 10)
: new Date().toISOString().slice(0, 10); : new Date().toISOString().slice(0, 10);
@@ -110,7 +110,7 @@ export function getActiveEvents(rows, location) {
return [...cosmicHits, ...weatherEvents]; return [...cosmicHits, ...weatherEvents];
} }
// ─── LENS EVENT PICKER ─────────────────────────────────────────────────── // --- LENS EVENT PICKER ---------------------------------------------------
// From an array of active events, returns the single one closest to its peak // From an array of active events, returns the single one closest to its peak
// (most "intense"). Weather events without a peak use today's date. // (most "intense"). Weather events without a peak use today's date.
// Returns null if events array is empty. // Returns null if events array is empty.
+7 -7
View File
@@ -1,14 +1,14 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// almanac-calendar.js Extended event calendar for the "What's Coming" // almanac-calendar.js - Extended event calendar for the "What's Coming"
// panel and getUpcomingEvents() export. // panel and getUpcomingEvents() export.
// //
// Includes all events from COSMIC_CALENDAR plus multi-year entries. // Includes all events from COSMIC_CALENDAR plus multi-year entries.
// Each entry has a `latHint`: // Each entry has a 'latHint':
// null = global / no hint // null = global / no hint
// 'north' = better from northern latitudes // 'north' = better from northern latitudes
// 'south' = better from southern latitudes // 'south' = better from southern latitudes
// 'path:...' = specific eclipse path note // 'path:...' = specific eclipse path note
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
export const ALMANAC_CALENDAR = [ export const ALMANAC_CALENDAR = [
// 2026 events (covers rest of year from today) // 2026 events (covers rest of year from today)
@@ -133,7 +133,7 @@ export const ALMANAC_CALENDAR = [
type: 'meteor', type: 'meteor',
}, },
// ── 2027 ───────────────────────────────────────────────────────────── // -- 2027 -------------------------------------------------------------
{ {
id: 'quadrantids-2027', id: 'quadrantids-2027',
emoji: '☄️', emoji: '☄️',
@@ -276,8 +276,8 @@ export function getVisibilityNote(entry, lat) {
return null; return null;
} }
// ─── MAIN ALMANAC EXPORT ───────────────────────────────────────────────── // --- MAIN ALMANAC EXPORT -------------------------------------------------
// Returns events with peak dates in the next `days` days (default 90), // Returns events with peak dates in the next 'days' days (default 90),
// sorted by peak date, with a visibility note added. // sorted by peak date, with a visibility note added.
export function getUpcomingEvents(location, days = 90) { export function getUpcomingEvents(location, days = 90) {
const today = new Date(); const today = new Date();
+9 -9
View File
@@ -1,5 +1,5 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// cosmic-calendar.js Hardcoded cosmic events (meteor showers, eclipses, // cosmic-calendar.js - Hardcoded cosmic events (meteor showers, eclipses,
// planetary oppositions/conjunctions) used by getActiveEvents. // planetary oppositions/conjunctions) used by getActiveEvents.
// //
// Each entry: { id, start: 'YYYY-MM-DD', end: 'YYYY-MM-DD', // Each entry: { id, start: 'YYYY-MM-DD', end: 'YYYY-MM-DD',
@@ -7,11 +7,11 @@
// Active if today falls within [start, end] (inclusive). // Active if today falls within [start, end] (inclusive).
// Night-only events (meteor showers, eclipses) are marked nightOnly: true // Night-only events (meteor showers, eclipses) are marked nightOnly: true
// so the cell icons only appear in night-time rows. // so the cell icons only appear in night-time rows.
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
export const COSMIC_CALENDAR = [ export const COSMIC_CALENDAR = [
// ── METEOR SHOWERS ────────────────────────────────────────────────── // -- METEOR SHOWERS --------------------------------------------------
{ {
id: 'quadrantids-2026', id: 'quadrantids-2026',
emoji: '☄️', emoji: '☄️',
@@ -101,7 +101,7 @@ export const COSMIC_CALENDAR = [
start: '2026-12-17', end: '2026-12-26', peak: '2026-12-22', start: '2026-12-17', end: '2026-12-26', peak: '2026-12-22',
}, },
// ── ECLIPSES ──────────────────────────────────────────────────────── // -- ECLIPSES --------------------------------------------------------
{ {
id: 'solar-eclipse-2026-aug', id: 'solar-eclipse-2026-aug',
emoji: '🌑', emoji: '🌑',
@@ -125,7 +125,7 @@ export const COSMIC_CALENDAR = [
start: '2026-03-03', end: '2026-03-03', start: '2026-03-03', end: '2026-03-03',
}, },
// ── PLANETARY EVENTS ──────────────────────────────────────────────── // -- PLANETARY EVENTS ------------------------------------------------
{ {
id: 'saturn-opposition-2026', id: 'saturn-opposition-2026',
emoji: '🪐', emoji: '🪐',
@@ -160,7 +160,7 @@ export const COSMIC_CALENDAR = [
start: '2026-06-30', end: '2026-07-02', start: '2026-06-30', end: '2026-07-02',
}, },
// ── 2027 METEOR SHOWERS ───────────────────────────────────────────── // -- 2027 METEOR SHOWERS ---------------------------------------------
{ {
id: 'quadrantids-2027', id: 'quadrantids-2027',
emoji: '☄️', emoji: '☄️',
@@ -234,7 +234,7 @@ export const COSMIC_CALENDAR = [
start: '2027-12-17', end: '2027-12-26', peak: '2027-12-22', start: '2027-12-17', end: '2027-12-26', peak: '2027-12-22',
}, },
// ── 2027 ECLIPSES ─────────────────────────────────────────────────── // -- 2027 ECLIPSES ---------------------------------------------------
{ {
id: 'annular-solar-eclipse-2027-feb', id: 'annular-solar-eclipse-2027-feb',
emoji: '🌑', emoji: '🌑',
@@ -254,7 +254,7 @@ export const COSMIC_CALENDAR = [
start: '2027-08-02', end: '2027-08-02', peak: '2027-08-02', start: '2027-08-02', end: '2027-08-02', peak: '2027-08-02',
}, },
// ── 2027 PLANETARY EVENTS ─────────────────────────────────────────── // -- 2027 PLANETARY EVENTS -------------------------------------------
{ {
id: 'mars-opposition-2027', id: 'mars-opposition-2027',
emoji: '🔴', emoji: '🔴',
+6 -6
View File
@@ -1,13 +1,13 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// dynamic-message.js Rewrites a cosmic event's message based on where // dynamic-message.js - Rewrites a cosmic event's message based on where
// today sits vs. the peak. // today sits vs. the peak.
// //
// Before peak : "is active and building peak on <date>. <detail>" // Before peak : "is active and building - peak on <date>. <detail>"
// On peak ±1d : "peaks tonight <detail>" // On peak -1d : "peaks tonight - <detail>"
// After peak : "is past its peak (<date>) but still possibly visible <detail>" // After peak : "is past its peak (<date>) but still possibly visible - <detail>"
// //
// Single-day events (start === end) keep their static message unchanged. // Single-day events (start === end) keep their static message unchanged.
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
export function dynamicCosmicMessage(ev, dateStr) { export function dynamicCosmicMessage(ev, dateStr) {
if (!ev.peak || ev.start === ev.end) return ev.message; if (!ev.peak || ev.start === ev.end) return ev.message;
+4 -4
View File
@@ -1,14 +1,14 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// lens-overlay.js Returns an SVG string for the event overlay inside // lens-overlay.js - Returns an SVG string for the event overlay inside
// the big ScopeReticle. // the big ScopeReticle.
// //
// Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers. // Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers.
// cx, cy = lens centre coords; lensR = lens radius. // cx, cy = lens centre coords; lensR = lens radius.
// //
// To add a new overlay for an event: add another `if (...)` block below // To add a new overlay for an event: add another 'if (...)' block below
// that matches by event.emoji or event.id and returns an SVG fragment // that matches by event.emoji or event.id and returns an SVG fragment
// string. Return null when no overlay applies. // string. Return null when no overlay applies.
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
export function getLensOverlaySVG(event, cx, cy, lensR) { export function getLensOverlaySVG(event, cx, cy, lensR) {
if (!event) return null; if (!event) return null;
+6 -6
View File
@@ -1,5 +1,5 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// weather-checks.js Weather-derived event detectors. // weather-checks.js - Weather-derived event detectors.
// //
// Computed in real-time from the forecast data. // Computed in real-time from the forecast data.
// Each checker function receives (rows [, location]) and returns an event // Each checker function receives (rows [, location]) and returns an event
@@ -9,7 +9,7 @@
// To add a new weather event: write a checkXxx(rows, location) function // To add a new weather event: write a checkXxx(rows, location) function
// below, export it, and add it to the checks[] array inside // below, export it, and add it to the checks[] array inside
// getActiveEvents() (in events.js). // getActiveEvents() (in events.js).
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
export function checkStargazing(rows) { export function checkStargazing(rows) {
// Great stargazing: mostly clear night hours with low cloud // Great stargazing: mostly clear night hours with low cloud
@@ -31,7 +31,7 @@ export function checkStargazing(rows) {
export function checkSunset(rows, location) { export function checkSunset(rows, location) {
// Find the actual sunset window: the LAST contiguous run of rows where // Find the actual sunset window: the LAST contiguous run of rows where
// solar elevation is in the golden/civil-twilight band (-3° to 8°). // solar elevation is in the golden/civil-twilight band (-3- to 8-).
// This distinguishes sunset from sunrise (which is the first such run). // This distinguishes sunset from sunrise (which is the first such run).
const twilightIndices = rows const twilightIndices = rows
.map((r, i) => ({ r, i })) .map((r, i) => ({ r, i }))
@@ -52,11 +52,11 @@ export function checkSunset(rows, location) {
runs.push(run); runs.push(run);
// Use the LAST run (sunset). If only one run exists it's either sunrise-only // Use the LAST run (sunset). If only one run exists it's either sunrise-only
// or a single dusk window use it but we'll label generically. // or a single dusk window - use it but we'll label generically.
const sunsetRun = runs[runs.length - 1]; const sunsetRun = runs[runs.length - 1];
const sunsetRows = sunsetRun.map(({ r }) => r); const sunsetRows = sunsetRun.map(({ r }) => r);
const isSunrise = runs.length === 1 && sunsetRows[0].elev < sunsetRows[sunsetRows.length - 1].elev; const isSunrise = runs.length === 1 && sunsetRows[0].elev < sunsetRows[sunsetRows.length - 1].elev;
// If sun is rising through the band this is a sunrise window, not sunset skip. // If sun is rising through the band this is a sunrise window, not sunset - skip.
if (isSunrise) return null; if (isSunrise) return null;
const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length; const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length;
+15 -15
View File
@@ -1,33 +1,33 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// useColumnPopup owns the column-header popup AND the event-tag popup. // useColumnPopup - owns the column-header popup AND the event-tag popup.
// //
// Both popups behave identically: // Both popups behave identically:
// click an anchor toggle the popup open // - click an anchor - toggle the popup open
// hover an anchor for N ms open // - hover an anchor for N ms - open
// move into the popup keep it open // - move into the popup - keep it open
// leave the popup close after 200 ms // - leave the popup - close after 200 ms
// click outside / scroll / resize close immediately // - click outside / scroll / resize - close immediately
// //
// Returns everything app.js needs to wire up the th cells and event-tag // Returns everything app.js needs to wire up the th cells and event-tag
// spans, plus the popup state objects for rendering the floating panels. // spans, plus the popup state objects for rendering the floating panels.
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js'; import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
export function useColumnPopup() { export function useColumnPopup() {
// ─── Column header popup ──────────────────────────────────────────── // --- Column header popup --------------------------------------------
const [colPopup, setColPopup] = useState(null); const [colPopup, setColPopup] = useState(null);
const colPopupRef = useRef(null); const colPopupRef = useRef(null);
const colPopupThRef = useRef(null); const colPopupThRef = useRef(null);
const hoverTimerRef = useRef(null); const hoverTimerRef = useRef(null);
const closeTimerRef = useRef(null); const closeTimerRef = useRef(null);
// ─── Event tag popup ──────────────────────────────────────────────── // --- Event tag popup ------------------------------------------------
// Holds { events[], slideIndex, x, y, arrowLeft, below } // Holds { events[], slideIndex, x, y, arrowLeft, below }
// When multiple events are in the popup they auto-cycle with a crossfade. // When multiple events are in the popup they auto-cycle with a crossfade.
const [eventTagPopup, setEventTagPopup] = useState(null); const [eventTagPopup, setEventTagPopup] = useState(null);
const [evSlideIndex, setEvSlideIndex] = useState(0); const [evSlideIndex, setEvSlideIndex] = useState(0);
// 'entering' | 'exiting' | null drives CSS crossfade classes // 'entering' | 'exiting' | null - drives CSS crossfade classes
const [evTransition, setEvTransition] = useState(null); const [evTransition, setEvTransition] = useState(null);
const prevSlideIndexRef = useRef(0); const prevSlideIndexRef = useRef(0);
const eventTagPopupRef = useRef(null); const eventTagPopupRef = useRef(null);
@@ -76,7 +76,7 @@ export function useColumnPopup() {
hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000); hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000);
}; };
// Leaving a th: just cancel the pending open. Don't auto-close // Leaving a th: just cancel the pending open. Don't auto-close -
// the user might be moving into the popup, or just passing through. // the user might be moving into the popup, or just passing through.
const handleThLeave = () => { const handleThLeave = () => {
clearTimeout(hoverTimerRef.current); clearTimeout(hoverTimerRef.current);
@@ -103,7 +103,7 @@ export function useColumnPopup() {
}; };
}, [colPopup]); }, [colPopup]);
// ─── Event tag popup helpers ───────────────────────────────────────── // --- Event tag popup helpers -----------------------------------------
const calcEventPopupPos = (spanEl) => { const calcEventPopupPos = (spanEl) => {
const rect = spanEl.getBoundingClientRect(); const rect = spanEl.getBoundingClientRect();
const popupW = 260, popupH = 80, margin = 8, gap = 6; const popupW = 260, popupH = 80, margin = 8, gap = 6;
@@ -116,7 +116,7 @@ export function useColumnPopup() {
return { x, y, arrowLeft, below }; return { x, y, arrowLeft, below };
}; };
// ─── Slideshow advance ─────────────────────────────────────────────── // --- Slideshow advance -----------------------------------------------
// evSlideTo triggers a crossfade to a new slide index. // evSlideTo triggers a crossfade to a new slide index.
const evSlideTo = useCallback((nextIndex) => { const evSlideTo = useCallback((nextIndex) => {
setEvTransition('exiting'); setEvTransition('exiting');
@@ -148,7 +148,7 @@ export function useColumnPopup() {
return () => clearInterval(evSlideTimerRef.current); return () => clearInterval(evSlideTimerRef.current);
}, [eventTagPopup, evSlideTo]); }, [eventTagPopup, evSlideTo]);
// ─── Event tag popup handlers ──────────────────────────────────────── // --- Event tag popup handlers ----------------------------------------
const openEventTagPopup = (events, spanEl) => { const openEventTagPopup = (events, spanEl) => {
clearInterval(evSlideTimerRef.current); clearInterval(evSlideTimerRef.current);
setEvSlideIndex(0); setEvSlideIndex(0);
+9 -9
View File
@@ -1,5 +1,5 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// useForecast fetches the weather forecast and air quality for the // useForecast - fetches the weather forecast and air quality for the
// given location and keeps them fresh. // given location and keeps them fresh.
// //
// Responsibilities: // Responsibilities:
@@ -11,15 +11,15 @@
// stale. // stale.
// //
// Inputs: // Inputs:
// location { lat, lon, name, country } // location - { lat, lon, name, country }
// //
// Outputs: // Outputs:
// forecast raw /v1/forecast response, or null // forecast - raw /v1/forecast response, or null
// airQuality raw /v1/air-quality response, or null // airQuality - raw /v1/air-quality response, or null
// loading true while the forecast fetch is in flight // loading - true while the forecast fetch is in flight
// error fetch error message, or null // error - fetch error message, or null
// now Date that ticks every 5 minutes (drives the "current row") // now - Date that ticks every 5 minutes (drives the "current row")
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { useState, useEffect } from '../../vendor/preact-hooks.js'; import { useState, useEffect } from '../../vendor/preact-hooks.js';
+19 -19
View File
@@ -1,13 +1,13 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// useTableScroll owns the horizontal scroll behaviour of the hourly // useTableScroll - owns the horizontal scroll behaviour of the hourly
// table (sticky header + body scroller layout). // table (sticky header + body scroller layout).
// //
// Responsibilities: // Responsibilities:
// 1. SCROLL INDICATORS track whether the body can scroll left/right so // 1. SCROLL INDICATORS - track whether the body can scroll left/right so
// the table edges can show fade + chevron indicators. // the table edges can show fade + chevron indicators.
// 2. DRAG-TO-SCROLL pointer-event drag on the body scroller for // 2. DRAG-TO-SCROLL - pointer-event drag on the body scroller for
// desktop users. // desktop users.
// 3. SCROLL SYNC keep the sticky header track shifted horizontally to // 3. SCROLL SYNC - keep the sticky header track shifted horizontally to
// match the body's scrollLeft, and keep header cell widths in lock- // match the body's scrollLeft, and keep header cell widths in lock-
// step with body cell widths even as columns toggle / window resizes. // step with body cell widths even as columns toggle / window resizes.
// //
@@ -15,12 +15,12 @@
// refs: { headTableRef, bodyTableRef, bodyScrollRef, headTrackRef } // refs: { headTableRef, bodyTableRef, bodyScrollRef, headTrackRef }
// deps: { forecast, visibleCols, selectedDay, skinType, vehicleType, // deps: { forecast, visibleCols, selectedDay, skinType, vehicleType,
// indoorMode, indoorManaged } // indoorMode, indoorManaged }
// anything that should cause a re-sync when it changes. // - anything that should cause a re-sync when it changes.
// //
// Outputs: // Outputs:
// tableCanScrollLeft, tableCanScrollRight drive the fade/chevron CSS // tableCanScrollLeft, tableCanScrollRight - drive the fade/chevron CSS
// handleBodyScroll attach to body onScroll // handleBodyScroll - attach to body onScroll
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { useState, useEffect, useLayoutEffect } from '../../vendor/preact-hooks.js'; import { useState, useEffect, useLayoutEffect } from '../../vendor/preact-hooks.js';
@@ -37,7 +37,7 @@ export function useTableScroll({
indoorMode, indoorMode,
indoorManaged, indoorManaged,
}) { }) {
// ─── SCROLL INDICATORS ───────────────────────────────────────────── // --- SCROLL INDICATORS ---------------------------------------------
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false); const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
const [tableCanScrollRight, setTableCanScrollRight] = useState(false); const [tableCanScrollRight, setTableCanScrollRight] = useState(false);
@@ -48,7 +48,7 @@ export function useTableScroll({
setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}; };
// ─── DRAG-TO-SCROLL ──────────────────────────────────────────────── // --- DRAG-TO-SCROLL ------------------------------------------------
useEffect(() => { useEffect(() => {
const el = bodyScrollRef.current; const el = bodyScrollRef.current;
if (!el) return; if (!el) return;
@@ -103,7 +103,7 @@ export function useTableScroll({
updateTableScrollIndicators(); updateTableScrollIndicators();
}, [forecast, visibleCols, selectedDay]); }, [forecast, visibleCols, selectedDay]);
// ─── BODY SCROLL HANDLER (called from JSX onScroll) ─────────────── // --- BODY SCROLL HANDLER (called from JSX onScroll) ---------------
const handleBodyScroll = () => { const handleBodyScroll = () => {
const track = headTrackRef.current; const track = headTrackRef.current;
const body = bodyScrollRef.current; const body = bodyScrollRef.current;
@@ -112,7 +112,7 @@ export function useTableScroll({
updateTableScrollIndicators(); updateTableScrollIndicators();
}; };
// ─── COLUMN-WIDTH SCROLL SYNC (layout effect) ───────────────────── // --- COLUMN-WIDTH SCROLL SYNC (layout effect) ---------------------
// Synchronise the head and body table column widths with a // Synchronise the head and body table column widths with a
// "shrink-to-fit then distribute" strategy. See the comments inside // "shrink-to-fit then distribute" strategy. See the comments inside
// sync() for the algorithm. // sync() for the algorithm.
@@ -132,7 +132,7 @@ export function useTableScroll({
// Step 1: clear any previously-forced cell widths and switch the // Step 1: clear any previously-forced cell widths and switch the
// tables to natural sizing so the measurement reflects the true // tables to natural sizing so the measurement reflects the true
// content-fit width independent of how wide the container is. // content-fit width - independent of how wide the container is.
headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
headTable.style.width = 'max-content'; headTable.style.width = 'max-content';
@@ -141,7 +141,7 @@ export function useTableScroll({
bodyTable.style.tableLayout = 'auto'; bodyTable.style.tableLayout = 'auto';
// Step 2: read each cell's natural width. getBoundingClientRect // Step 2: read each cell's natural width. getBoundingClientRect
// forces synchronous layout that's what we want. // forces synchronous layout - that's what we want.
const naturalW = new Array(n); const naturalW = new Array(n);
let naturalTotal = 0; let naturalTotal = 0;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@@ -157,7 +157,7 @@ export function useTableScroll({
const finalW = new Array(n); const finalW = new Array(n);
let totalWidth; let totalWidth;
if (naturalTotal > 0 && naturalTotal < containerW) { if (naturalTotal > 0 && naturalTotal < containerW) {
// Spare space distribute proportionally across columns so they // Spare space - distribute proportionally across columns so they
// fan out to fill the scroller (no awkward right-hand gap). // fan out to fill the scroller (no awkward right-hand gap).
const scale = containerW / naturalTotal; const scale = containerW / naturalTotal;
let running = 0; let running = 0;
@@ -170,7 +170,7 @@ export function useTableScroll({
finalW[n - 1] = containerW - running; finalW[n - 1] = containerW - running;
totalWidth = containerW; totalWidth = containerW;
} else { } else {
// Naturals don't fit use them as-is and let the body scroll. // Naturals don't fit - use them as-is and let the body scroll.
for (let i = 0; i < n; i++) finalW[i] = naturalW[i]; for (let i = 0; i < n; i++) finalW[i] = naturalW[i];
totalWidth = naturalTotal; totalWidth = naturalTotal;
} }
@@ -192,7 +192,7 @@ export function useTableScroll({
bodyCells[i].style.maxWidth = px; bodyCells[i].style.maxWidth = px;
} }
// Make both tables exactly totalWidth wide so they share the same // Make both tables exactly totalWidth wide so they share the same
// horizontal extent column N in the header sits directly above // horizontal extent - column N in the header sits directly above
// column N in the body, no drift as you scroll right. // column N in the body, no drift as you scroll right.
headTable.style.width = `${totalWidth}px`; headTable.style.width = `${totalWidth}px`;
bodyTable.style.width = `${totalWidth}px`; bodyTable.style.width = `${totalWidth}px`;
@@ -202,7 +202,7 @@ export function useTableScroll({
// Run once after layout // Run once after layout
sync(); sync();
// Re-sync when the scroll container's width changes (window resize, // Re-sync when the scroll container's width changes (window resize,
// sidebar opens, etc). We observe the scroller not the body table // sidebar opens, etc). We observe the scroller - not the body table -
// because the body table's width is now driven by sync itself, which // because the body table's width is now driven by sync itself, which
// would otherwise create a feedback loop. // would otherwise create a feedback loop.
let ro = null; let ro = null;
+3 -3
View File
@@ -1,5 +1,5 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// main.js Entry point. Imports the app and mounts it into the page. // main.js - Entry point. Imports the app and mounts it into the page.
// //
// This is the file loaded by index.html as <script type="module">. // This is the file loaded by index.html as <script type="module">.
// It does nothing except wire the root component to the DOM node. // It does nothing except wire the root component to the DOM node.
@@ -8,7 +8,7 @@
// 1. index.html contains <div id="root"></div> // 1. index.html contains <div id="root"></div>
// 2. index.html loads THIS file as <script type="module" src="..."> // 2. index.html loads THIS file as <script type="module" src="...">
// 3. The browser console (F12) shows no import errors // 3. The browser console (F12) shows no import errors
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { h, render } from '../vendor/preact.js'; import { h, render } from '../vendor/preact.js';
import { UTCIForecast } from './app.js'; import { UTCIForecast } from './app.js';
+121 -121
View File
@@ -1,5 +1,5 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// physics.js Physical constants and meteorological calculations. // physics.js - Physical constants and meteorological calculations.
// //
// All numbers are peer-reviewed constants or coefficients. Nothing in // All numbers are peer-reviewed constants or coefficients. Nothing in
// here should need editing unless the underlying science changes. // here should need editing unless the underlying science changes.
@@ -10,63 +10,63 @@
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp // calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp // calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated) // calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
// vaporPressureHpa(Ta, RH) Magnus formula hPa // vaporPressureHpa(Ta, RH) Magnus formula - hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (°) // solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (-)
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temperature // calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temperature
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 UTCI polynomial // utciApprox(Ta, Tmrt, va10, ehPa) Br-de et al. 2012 UTCI polynomial
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
import { VEHICLE_TYPES, BUILDING_TYPES } from './utils.js'; import { VEHICLE_TYPES, BUILDING_TYPES } from './utils.js';
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// PHYSICAL CONSTANTS // PHYSICAL CONSTANTS
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// These are real-world physics values don't change them unless you // These are real-world physics values - don't change them unless you
// have a peer-reviewed reason. They're used by calcTmrt() below to // have a peer-reviewed reason. They're used by calcTmrt() below to
// work out how much heat your skin actually absorbs from the sun. // work out how much heat your skin actually absorbs from the sun.
// SIGMA StefanBoltzmann constant (radiates heat) // SIGMA - Stefan-Boltzmann constant (radiates heat)
// EPSILON_P emissivity of human skin (~0.97) // EPSILON_P - emissivity of human skin (~0.97)
// A_K short-wave absorption coefficient for clothing // A_K - short-wave absorption coefficient for clothing
// ALBEDO_GRASS how much sun grass reflects back at you (23%) // ALBEDO_GRASS - how much sun grass reflects back at you (23%)
// ALBEDO_CONCRETE how much sun concrete reflects back (30%) // ALBEDO_CONCRETE - how much sun concrete reflects back (30%)
// Concrete absorbs more net solar than grass and has // Concrete absorbs more net solar than grass and has
// no evaporative cooling, so its surface runs hot. // no evaporative cooling, so its surface runs hot.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export const SIGMA = 5.670374419e-8; export const SIGMA = 5.670374419e-8;
export const EPSILON_P = 0.97; export const EPSILON_P = 0.97;
export const A_K = 0.7; export const A_K = 0.7;
export const ALBEDO_GRASS = 0.23; export const ALBEDO_GRASS = 0.23;
export const ALBEDO_CONCRETE = 0.30; export const ALBEDO_CONCRETE = 0.30;
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// CONCRETE SURFACE TEMPERATURE (Urban profile) // CONCRETE SURFACE TEMPERATURE (Urban profile)
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Estimates the surface temperature of exposed concrete using a // Estimates the surface temperature of exposed concrete using a
// simplified energy-balance approach: // simplified energy-balance approach:
// Absorbed solar = globalRad × (1 albedo) // - Absorbed solar = globalRad - (1 - albedo)
// No latent heat (no evaporation concrete is dry) // - No latent heat (no evaporation - concrete is dry)
// Convective loss to air proportional to wind speed // - Convective loss to air proportional to wind speed
// Result is clamped to a physically plausible range // - Result is clamped to a physically plausible range
// //
// This is what matters for contact heat stress in cities the UTCI // This is what matters for contact heat stress in cities - the UTCI
// standard uses grass, which runs ~515 °C cooler than urban concrete // standard uses grass, which runs ~5-15 -C cooler than urban concrete
// on a sunny day because grass sweats (transpires). // on a sunny day because grass sweats (transpires).
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function calcConcreteTemp(Ta, globalRad, windSpeed) { export function calcConcreteTemp(Ta, globalRad, windSpeed) {
if (globalRad == null || Ta == null) return null; if (globalRad == null || Ta == null) return null;
const absorbed = globalRad * (1 - ALBEDO_CONCRETE); // W/m² const absorbed = globalRad * (1 - ALBEDO_CONCRETE); // W/m-
// Convective heat transfer coefficient: ~10 W/m²K still air, rises with wind // Convective heat transfer coefficient: ~10 W/m-K still air, rises with wind
// (10 reflects realistic natural convection; 5 was too low and ran too hot) // (10 reflects realistic natural convection; 5 was too low and ran too hot)
const hc = 10 + 4.5 * Math.sqrt(Math.max(windSpeed || 0, 0)); const hc = 10 + 4.5 * Math.sqrt(Math.max(windSpeed || 0, 0));
// Surface temp: Ta + solar gain / convective loss // Surface temp: Ta + solar gain / convective loss
const Ts = Ta + absorbed / hc; const Ts = Ta + absorbed / hc;
// Clamp: can't be cooler than air, cap at 85 °C (melting asphalt territory) // Clamp: can't be cooler than air, cap at 85 -C (melting asphalt territory)
return Math.max(Ta, Math.min(Ts, 85)); return Math.max(Ta, Math.min(Ts, 85));
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE (windows closed, no active cooling) // UK HOUSE INDOOR TEMPERATURE (windows closed, no active cooling)
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Estimates the ambient indoor air temperature of a typical UK brick // Estimates the ambient indoor air temperature of a typical UK brick
// house with windows closed and no air conditioning. // house with windows closed and no air conditioning.
// //
@@ -74,37 +74,37 @@ export function calcConcreteTemp(Ta, globalRad, windSpeed) {
// //
// 1. WALL CONDUCTION // 1. WALL CONDUCTION
// Heat conducts through brick cavity walls and roof. UK Part L // Heat conducts through brick cavity walls and roof. UK Part L
// compliant walls have a U-value around 0.280.45 W/m²K; older // 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 // solid-brick stock runs higher. A representative mid-stock value
// is used. This drives a slow, steady heat transfer proportional // is used. This drives a slow, steady heat transfer proportional
// to the difference between outdoor and indoor air temperature. // to the difference between outdoor and indoor air temperature.
// //
// 2. WINDOW SOLAR GAIN // 2. WINDOW SOLAR GAIN
// A typical UK semi has ~1518% glazing ratio. Solar energy // A typical UK semi has ~15-18% glazing ratio. Solar energy
// transmits through glass, is absorbed by floors and furniture, // transmits through glass, is absorbed by floors and furniture,
// and heats the indoor air. Gain is averaged across orientations // and heats the indoor air. Gain is averaged across orientations
// (not all windows face south). Diffuse radiation contributes // (not all windows face south). Diffuse radiation contributes
// regardless of sun angle. // regardless of sun angle.
// //
// THERMAL LAG // THERMAL LAG
// Brick and concrete have high thermal mass the house responds // Brick and concrete have high thermal mass - the house responds
// slowly to outdoor temperature swings. Each hour builds a realistic // slowly to outdoor temperature swings. Each hour builds a realistic
// target temperature from outdoor air, window solar gain, retained // target temperature from outdoor air, window solar gain, retained
// warmth, and internal gains, then the room temperature lags toward // warmth, and internal gains, then the room temperature lags toward
// that target. This avoids runaway accumulation while still giving // that target. This avoids runaway accumulation while still giving
// the characteristic late-day indoor peak. // the characteristic late-day indoor peak.
// Call calcIndoorTempPass() on the full hourly arrays after // Call calcIndoorTempPass() on the full hourly arrays after
// building rows it returns a per-hour indoor temp array. // building rows - it returns a per-hour indoor temp array.
// //
// No mechanical cooling. Minimal infiltration (windows closed). // No mechanical cooling. Minimal infiltration (windows closed).
// Internal heat gains (people, appliances) are not modelled. // Internal heat gains (people, appliances) are not modelled.
// //
// Colour thresholds: // Colour thresholds:
// < 20 °C cool, may need heating // < 20 -C - cool, may need heating
// 2026 °C comfortable // 20-26 -C - comfortable
// 2632 °C warm; WHO heatwave advisory threshold for sleeping // 26-32 -C - warm; WHO heatwave advisory threshold for sleeping
// > 32 °C hot; risk for elderly and vulnerable occupants // > 32 -C - hot; risk for elderly and vulnerable occupants
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// Two-pass function: call with the full arrays of hourly Ta and globalRad. // Two-pass function: call with the full arrays of hourly Ta and globalRad.
// Returns an array of indoor temperatures, one per hour. // Returns an array of indoor temperatures, one per hour.
@@ -124,7 +124,7 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
const Ta = TaArr[i] ?? Ti; const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0; const glob = globArr[i] ?? 0;
// Solar gain through windows (W/m² effective) // Solar gain through windows (W/m- effective)
const solarGain = glob * glazingRatio * gValue * orientFactor; const solarGain = glob * glazingRatio * gValue * orientFactor;
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35); const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
@@ -141,9 +141,9 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
return result; return result;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE MANAGED (curtains closed, windows open) // UK HOUSE INDOOR TEMPERATURE - MANAGED (curtains closed, windows open)
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Models the same UK brick house as calcIndoorTempPass but with two // Models the same UK brick house as calcIndoorTempPass but with two
// behavioural interventions that reflect standard heatwave advice: // behavioural interventions that reflect standard heatwave advice:
// //
@@ -156,11 +156,11 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
// When outdoor air is cooler than the indoor air, windows are open // When outdoor air is cooler than the indoor air, windows are open
// and a ventilation heat exchange pulls the indoor temp toward // and a ventilation heat exchange pulls the indoor temp toward
// outdoor. When outdoor is hotter than indoor, windows are kept // outdoor. When outdoor is hotter than indoor, windows are kept
// shut so this strategy never makes things worse, only better. // shut - so this strategy never makes things worse, only better.
// Ventilation rate ~2 air changes/hour for a well-opened house. // Ventilation rate ~2 air changes/hour for a well-opened house.
// //
// Same thermal lag model as calcIndoorTempPass (4 h brick time constant). // Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'. // buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') { export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick; const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
@@ -176,7 +176,7 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
const Ta = TaArr[i] ?? Ti; const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0; const glob = globArr[i] ?? 0;
// Solar gain curtains block curtainBlock fraction // Solar gain - curtains block curtainBlock fraction
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock); const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35); const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
@@ -196,9 +196,9 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
return result; return result;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// VEHICLE INTERIOR CABIN TEMPERATURE (seated occupant, not in sunbeam) // VEHICLE INTERIOR CABIN TEMPERATURE (seated occupant, not in sunbeam)
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Models the ambient cabin air temperature experienced by an occupant // Models the ambient cabin air temperature experienced by an occupant
// seated out of direct sunlight inside a sealed, parked vehicle. // seated out of direct sunlight inside a sealed, parked vehicle.
// Two heat sources are combined: // Two heat sources are combined:
@@ -207,72 +207,72 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
// Aluminium body panels absorb solar radiation and conduct heat // Aluminium body panels absorb solar radiation and conduct heat
// into the cabin. Albedo ~0.25 (mid-point for typical mixed-colour // into the cabin. Albedo ~0.25 (mid-point for typical mixed-colour
// fleet; dark paint ~0.10, silver/white ~0.40). // fleet; dark paint ~0.10, silver/white ~0.40).
// Panel surface temp conductive gain into cabin air. // Panel surface temp - conductive gain into cabin air.
// //
// 2. SIDE-WINDOW GLAZING GAIN (sun-angle dependent) // 2. SIDE-WINDOW GLAZING GAIN (sun-angle dependent)
// When solar elevation is between ~10° and ~60°, the sun's rays // When solar elevation is between ~10- and ~60-, the sun's rays
// cut through the side glass at an angle that allows significant // cut through the side glass at an angle that allows significant
// transmission into the cabin (rather than hitting the roof or // transmission into the cabin (rather than hitting the roof or
// reflecting off at a shallow angle). This warms the cabin air // reflecting off at a shallow angle). This warms the cabin air
// but the occupant is modelled as NOT sitting in the beam // but the occupant is modelled as NOT sitting in the beam -
// so it adds to ambient cabin temp, not direct radiant load. // so it adds to ambient cabin temp, not direct radiant load.
// Above 60° the sun mostly hits the roof; below 10° it reflects. // Above 60- the sun mostly hits the roof; below 10- it reflects.
// //
// Wind is ignored unless ventilation is enabled. No evaporative cooling. // Wind is ignored unless ventilation is enabled. No evaporative cooling.
// Cars warm quickly; motorhomes and caravans are treated as insulated living // Cars warm quickly; motorhomes and caravans are treated as insulated living
// spaces with 2535 mm sandwich panels, so panel heat gain is much smaller // spaces with 25-35 mm sandwich panels, so panel heat gain is much smaller
// and the interior response is slower than a car cabin. Because they are // and the interior response is slower than a car cabin. Because they are
// occupied living spaces, they also retain warmth from previous hours, people, // occupied living spaces, they also retain warmth from previous hours, people,
// appliances, and background heating; without that, cool sunny days are // appliances, and background heating; without that, cool sunny days are
// under-estimated badly. // under-estimated badly.
// //
// Colour thresholds in the table: // Colour thresholds in the table:
// < 35 °C warm but tolerable for short periods // < 35 -C - warm but tolerable for short periods
// 3545 °C dangerous for children/pets (hyperthermia risk) // 35-45 -C - dangerous for children/pets (hyperthermia risk)
// > 45 °C potentially fatal within minutes // > 45 -C - potentially fatal within minutes
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false) { export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false) {
if (globalRad == null || Ta == null) return null; if (globalRad == null || Ta == null) return null;
// Look up vehicle preset; fall back to a standard car if key unknown. // Look up vehicle preset; fall back to a standard car if key unknown.
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car; const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
// ── 1. Panel conduction ────────────────────────────────────────── // -- 1. Panel conduction ------------------------------------------
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m² absorbed by bodywork const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m- absorbed by bodywork
// Panel surface temp: absorbed solar / convective loss to outside air // Panel surface temp: absorbed solar / convective loss to outside air
// hOut ~10 W/m²K (light breeze over panel surface even when parked) // hOut ~10 W/m-K (light breeze over panel surface even when parked)
const hOut = 10; const hOut = 10;
const panelSurfaceTemp = Ta + panelAbsorbed / hOut; const panelSurfaceTemp = Ta + panelAbsorbed / hOut;
// Conductive gain into cabin. Cars are thin metal + trim; motorhomes and // Conductive gain into cabin. Cars are thin metal + trim; motorhomes and
// caravans use insulated sandwich panels, so their bodyU is much lower. // caravans use insulated sandwich panels, so their bodyU is much lower.
const hCabin = preset.bodyU ?? 4; const hCabin = preset.bodyU ?? 4;
const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m² const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m-
// ── 2. Side-window glazing gain (angle-dependent) ───────────────── // -- 2. Side-window glazing gain (angle-dependent) -----------------
// Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area. // Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area.
const tau = 0.70 * preset.glazingArea; const tau = 0.70 * preset.glazingArea;
let glazingGain = 0; let glazingGain = 0;
if (solElev != null && solElev > 10 && solElev < 60) { if (solElev != null && solElev > 10 && solElev < 60) {
// Scale factor: peaks around 3040° elevation (sun cuts squarely // Scale factor: peaks around 30-40- elevation (sun cuts squarely
// through side glass), tapers off toward 10° (shallow/reflected) // through side glass), tapers off toward 10- (shallow/reflected)
// and 60° (sun increasingly hitting roof not side glass). // and 60- (sun increasingly hitting roof not side glass).
// Use a simple tent function peaking at 35°. // Use a simple tent function peaking at 35-.
const peak = 35; const peak = 35;
const halfWidth = 25; // degrees either side const halfWidth = 25; // degrees either side
const factor = Math.max(0, 1 - Math.abs(solElev - peak) / halfWidth); const factor = Math.max(0, 1 - Math.abs(solElev - peak) / halfWidth);
// Diffuse radiation also enters through glass regardless of angle // Diffuse radiation also enters through glass regardless of angle
glazingGain = tau * globalRad * factor * 0.5; // occupant not in beam 50% ambient glazingGain = tau * globalRad * factor * 0.5; // occupant not in beam - 50% ambient
} else { } else {
// Outside the side-window zone: diffuse only (scattered sky light) // Outside the side-window zone: diffuse only (scattered sky light)
glazingGain = tau * (globalRad * 0.15); // ~15% diffuse fraction glazingGain = tau * (globalRad * 0.15); // ~15% diffuse fraction
} }
// ── Combine into cabin air temperature ─────────────────────────── // -- Combine into cabin air temperature ---------------------------
// Total heat input per m² of cabin surface // Total heat input per m- of cabin surface
const totalGain = conductionGain + glazingGain; const totalGain = conductionGain + glazingGain;
// Cabin heat rejection: an effective blend of leakage, internal air volume, // Cabin heat rejection: an effective blend of leakage, internal air volume,
// and surfaces exchanging heat with the outside. With windows open it is // and surfaces exchanging heat with the outside. With windows open it is
// roughly 5× higher air moves freely // roughly 5- higher - air moves freely
// through the cabin, flushing heat out and capping interior temperature much // through the cabin, flushing heat out and capping interior temperature much
// closer to ambient. Cabin temp still rises a little due to panel/roof solar gain. // closer to ambient. Cabin temp still rises a little due to panel/roof solar gain.
const effectiveHLoss = ventilated ? preset.hCabinLoss * 5 : preset.hCabinLoss; const effectiveHLoss = ventilated ? preset.hCabinLoss * 5 : preset.hCabinLoss;
@@ -289,16 +289,16 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
const Ti = Ta + solarRise + retainedWarmth + internalGain; const Ti = Ta + solarRise + retainedWarmth + internalGain;
// Clamp: can't be cooler than outside air; physical cap at 90 °C // Clamp: can't be cooler than outside air; physical cap at 90 -C
return Math.max(Ta, Math.min(Ti, 90)); return Math.max(Ta, Math.min(Ti, 90));
} }
// ── FUTURE FEATURE v2: Pets Profile Fur Temperature & Heat Stress ────────── // -- FUTURE FEATURE v2: Pets Profile - Fur Temperature & Heat Stress ----------
// Dogs and cats experience heat very differently from humans: // Dogs and cats experience heat very differently from humans:
// //
// FUR SURFACE TEMPERATURE // FUR SURFACE TEMPERATURE
// Dark/thick fur absorbs solar radiation and can run significantly hotter // Dark/thick fur absorbs solar radiation and can run significantly hotter
// than air temperature similar in principle to calcConcreteTemp() but // than air temperature - similar in principle to calcConcreteTemp() but
// with fur-specific albedo values: // with fur-specific albedo values:
// Black fur: albedo ~0.05 (almost all radiation absorbed) // Black fur: albedo ~0.05 (almost all radiation absorbed)
// Brown fur: albedo ~0.15 // Brown fur: albedo ~0.15
@@ -306,44 +306,44 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
// White fur: albedo ~0.40 // White fur: albedo ~0.40
// Add a calcFurSurfaceTemp(Ta, globalRad, windSpeed, furAlbedo) function // Add a calcFurSurfaceTemp(Ta, globalRad, windSpeed, furAlbedo) function
// mirroring calcConcreteTemp but with appropriate convective coefficients // mirroring calcConcreteTemp but with appropriate convective coefficients
// for fur (lower hc than concrete fur insulates). // for fur (lower hc than concrete - fur insulates).
// //
// PAW BURN RISK // PAW BURN RISK
// Paw pads are highly sensitive to surface temperature. Use calcConcreteTemp() // Paw pads are highly sensitive to surface temperature. Use calcConcreteTemp()
// output directly the existing concrete surface temp is already the right // output directly - the existing concrete surface temp is already the right
// number. Threshold guidance: // number. Threshold guidance:
// < 40 °C safe // < 40 -C - safe
// 4052 °C discomfort / possible burn (hold-your-hand-for-7-seconds test) // 40-52 -C - discomfort / possible burn (hold-your-hand-for-7-seconds test)
// > 52 °C burns within 60 seconds // > 52 -C - burns within 60 seconds
// Display as a simple traffic-light column in the Pets profile. // Display as a simple traffic-light column in the Pets profile.
// //
// BREED-SPECIFIC HEAT STRESS // BREED-SPECIFIC HEAT STRESS
// Brachycephalic breeds (Bulldogs, Pugs, French Bulldogs, Persians) have // Brachycephalic breeds (Bulldogs, Pugs, French Bulldogs, Persians) have
// severely impaired thermoregulation their safe UTCI ceiling is much lower. // severely impaired thermoregulation - their safe UTCI ceiling is much lower.
// A breed selector (Normal / Brachycephalic / Senior) applies a risk multiplier // A breed selector (Normal / Brachycephalic / Senior) applies a risk multiplier
// to the UTCI thresholds, similar to the planned activity modifier. // to the UTCI thresholds, similar to the planned activity modifier.
// //
// SUGGESTED COLUMNS FOR PETS PROFILE // SUGGESTED COLUMNS FOR PETS PROFILE
// Hour | Air Temp | UTCI+P | Fur Surface Temp | Paw Burn Risk | Shade Advised // Hour | Air Temp | UTCI+P | Fur Surface Temp | Paw Burn Risk | Shade Advised
// //
// ───────────────────────────────────────────────────────────────────────────── // -----------------------------------------------------------------------------
// ── FUTURE FEATURE: Vehicle-at-speed thermal model ──────────────────────────── // -- FUTURE FEATURE: Vehicle-at-speed thermal model ----------------------------
// Idea: extend calcVehicleInteriorTemp (or add a companion function) to model // Idea: extend calcVehicleInteriorTemp (or add a companion function) to model
// cabin temperature for a vehicle travelling at speed, not just parked. // cabin temperature for a vehicle travelling at speed, not just parked.
// //
// Key physics differences from the static model: // Key physics differences from the static model:
// Forced convection over the shell scales with vehicle speed (v²), so // - Forced convection over the shell scales with vehicle speed (v-), so
// hOut rises significantly shell cools much faster than when parked. // hOut rises significantly - shell cools much faster than when parked.
// Above ~30 mph the vehicle's own forward motion dominates airflow, so // - Above ~30 mph the vehicle's own forward motion dominates airflow, so
// ambient wind direction becomes largely irrelevant (simplifies the model). // ambient wind direction becomes largely irrelevant (simplifies the model).
// Speed classes to model: urban (~20 mph), dual carriageway (~50 mph), // - Speed classes to model: urban (~20 mph), dual carriageway (~50 mph),
// motorway (~70 mph) each with a derived hOut multiplier. // motorway (~70 mph) - each with a derived hOut multiplier.
// Windows-open behaviour changes completely at speed: at 70 mph open // - Windows-open behaviour changes completely at speed: at 70 mph open
// windows create high-velocity through-flow, dramatically cutting cabin // windows create high-velocity through-flow, dramatically cutting cabin
// temp vs. the sealed-car case (but much less pleasant than AC!). // temp vs. the sealed-car case (but much less pleasant than AC!).
// Roof and bonnet solar gain stays the same; side-glass gain is unchanged. // - Roof and bonnet solar gain stays the same; side-glass gain is unchanged.
// AC-off vs AC-on would be the primary user toggle alongside speed class. // - AC-off vs AC-on would be the primary user toggle alongside speed class.
// //
// Suggested signature: // Suggested signature:
// calcVehicleInteriorTempAtSpeed(Ta, globalRad, solElev, vehicleType, speedMph, windowsOpen) // calcVehicleInteriorTempAtSpeed(Ta, globalRad, solElev, vehicleType, speedMph, windowsOpen)
@@ -351,42 +351,42 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
// This would be a useful companion output column (e.g. "Vehicle (moving)") // This would be a useful companion output column (e.g. "Vehicle (moving)")
// for road-trip planning, dog-in-car safety at a rest stop vs. motorway, etc. // for road-trip planning, dog-in-car safety at a rest stop vs. motorway, etc.
// //
// ── CYCLIST AT SPEED (companion to the above) ──────────────────────────────── // -- CYCLIST AT SPEED (companion to the above) --------------------------------
// A cyclist generates their own headwind, so the felt temperature (UTCI) is // A cyclist generates their own headwind, so the felt temperature (UTCI) is
// very different from a stationary person even without ambient wind. // very different from a stationary person - even without ambient wind.
// Could share the same speed-class approach as the vehicle model: // Could share the same speed-class approach as the vehicle model:
// Slow (10 mph) / Moderate (15 mph) / Fast (25 mph) // Slow (10 mph) / Moderate (15 mph) / Fast (25 mph)
// //
// Key differences from the vehicle model: // Key differences from the vehicle model:
// The cyclist IS the exposed person use the UTCI polynomial directly // - The cyclist IS the exposed person - use the UTCI polynomial directly
// with va = max(ambientWind, cyclingSpeed * conversionFactor) // with va = max(ambientWind, cyclingSpeed * conversionFactor)
// No cabin heating effect the cyclist gets wind chill, not solar entrapment // - No cabin heating effect - the cyclist gets wind chill, not solar entrapment
// High metabolic heat generation raises core temp (links to the activity // - High metabolic heat generation raises core temp (links to the activity
// modifier planned in compute.js cycling at speed is a combined effect) // modifier planned in compute.js - cycling at speed is a combined effect)
// UVA/UVB exposure is unchanged still fully exposed to the sun // - UVA/UVB exposure is unchanged - still fully exposed to the sun
// //
// Could be a sub-option within the existing Cycling activity variant rather // Could be a sub-option within the existing Cycling activity variant rather
// than a separate column e.g. a speed picker in the variant controls. // than a separate column - e.g. a speed picker in the variant controls.
// ───────────────────────────────────────────────────────────────────────────── // -----------------------------------------------------------------------------
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// VAPOUR PRESSURE Magnus formula. // VAPOUR PRESSURE - Magnus formula.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Converts air temperature (°C) and relative humidity (%) to vapour // Converts air temperature (-C) and relative humidity (%) to vapour
// pressure in hPa. Used as the humidity input to utciApprox(). // pressure in hPa. Used as the humidity input to utciApprox().
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function vaporPressureHpa(Ta, RH) { export function vaporPressureHpa(Ta, RH) {
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta)); const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return es * (RH / 100); return es * (RH / 100);
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// SOLAR ELEVATION NOAA simplified algorithm (degrees above horizon). // SOLAR ELEVATION - NOAA simplified algorithm (degrees above horizon).
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Accurate to within ~0.01° for most practical purposes. Returns a // Accurate to within ~0.01- for most practical purposes. Returns a
// negative value when the sun is below the horizon (civil twilight // negative value when the sun is below the horizon (civil twilight
// starts at , nautical at 12°, astronomical at 18°). // starts at -6-, nautical at -12-, astronomical at -18-).
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function solarElevationDeg(lat, lon, dateUTC) { export function solarElevationDeg(lat, lon, dateUTC) {
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0); const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
const diff = dateUTC - start; const diff = dateUTC - start;
@@ -422,21 +422,21 @@ export function solarElevationDeg(lat, lon, dateUTC) {
return (Math.PI / 2 - zenith) * (180 / Math.PI); return (Math.PI / 2 - zenith) * (180 / Math.PI);
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// MEAN RADIANT TEMPERATURE (Tmrt) // MEAN RADIANT TEMPERATURE (Tmrt)
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Tmrt is the uniform temperature of an imaginary enclosure that would // Tmrt is the uniform temperature of an imaginary enclosure that would
// cause the same net radiation exchange as the actual environment. // cause the same net radiation exchange as the actual environment.
// It accounts for: // It accounts for:
// Direct solar beam (DNI), scaled by the projected-area factor fp // - Direct solar beam (DNI), scaled by the projected-area factor fp
// Diffuse sky radiation (scattered and cloud-reflected) // - Diffuse sky radiation (scattered and cloud-reflected)
// Ground-reflected shortwave (albedo × global radiation) // - Ground-reflected shortwave (albedo - global radiation)
// Longwave thermal emission from surrounding surfaces ( blackbody at Ta) // - Longwave thermal emission from surrounding surfaces (- blackbody at Ta)
// //
// The fabric index 0.308 (fp formula from ISO 7933) projects the sun // The fabric index 0.308 (fp formula from ISO 7933) projects the sun
// onto a standing person's silhouette as a function of solar elevation. // onto a standing person's silhouette as a function of solar elevation.
// Output feeds directly into utciApprox() as the Tmrt argument. // Output feeds directly into utciApprox() as the Tmrt argument.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) { export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
const TaK = Ta + 273.15; const TaK = Ta + 273.15;
let fp = 0; let fp = 0;
@@ -455,14 +455,14 @@ export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
return TmrtK - 273.15; return TmrtK - 273.15;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// UTCI POLYNOMIAL DO NOT EDIT (or be VERY careful if you do) // UTCI POLYNOMIAL - DO NOT EDIT (or be VERY careful if you do)
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// This is the official 210-term Bröde et al. (2012) approximation. // This is the official 210-term Br-de et al. (2012) approximation.
// It takes air temp, mean radiant temp, wind, and humidity and returns // It takes air temp, mean radiant temp, wind, and humidity and returns
// the "felt" temperature. Every number is a peer-reviewed coefficient. // the "felt" temperature. Every number is a peer-reviewed coefficient.
// Scroll past it there's nothing here you'll want to change. // Scroll past it - there's nothing here you'll want to change.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function utciApprox(Ta, Tmrt, va10, ehPa) { export function utciApprox(Ta, Tmrt, va10, ehPa) {
const va = Math.max(0.5, Math.min(17, va10)); const va = Math.max(0.5, Math.min(17, va10));
const D_Tmrt = Tmrt - Ta; const D_Tmrt = Tmrt - Ta;
+100 -100
View File
@@ -1,35 +1,35 @@
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// utils.js Pure helper functions and lookup tables. // utils.js - Pure helper functions and lookup tables.
// //
// No side-effects, no DOM access, no API calls. All functions are safe // No side-effects, no DOM access, no API calls. All functions are safe
// to call server-side or in tests. // to call server-side or in tests.
// //
// Exports (in order of appearance): // Exports (in order of appearance):
// utciCategory(u) UTCI stress band {label,bg,fg} // utciCategory(u) UTCI stress band - {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (°C penalty) // precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (-C penalty)
// windCompass8(deg) bearing {label, snapped} // windCompass8(deg) bearing - {label, snapped}
// uvSplit(uv, elevDeg) total UV index {uvA, uvB} estimate // uvSplit(uv, elevDeg) total UV index - {uvA, uvB} estimate
// SKIN_TYPES Fitzpatrick IVI lookup table // SKIN_TYPES Fitzpatrick I-VI lookup table
// sunburnMinutes(uv, skinType) minutes to MED (sunburn threshold) // sunburnMinutes(uv, skinType) minutes to MED (sunburn threshold)
// burnLabel(mins) formats burn time as "12m" / "1.5h" // burnLabel(mins) formats burn time as "12m" / "1.5h"
// VEHICLE_TYPES vehicle presets for cabin heat model // VEHICLE_TYPES vehicle presets for cabin heat model
// BUILDING_TYPES building presets for indoor heat model // BUILDING_TYPES building presets for indoor heat model
// cloudCategory(total,low,mid,high) 'clear'|'wispy'|'scattered'|'overcast' // cloudCategory(total,low,mid,high) - 'clear'|'wispy'|'scattered'|'overcast'
// confidenceBand(i) day-tab gradient colour + label // confidenceBand(i) day-tab gradient colour + label
// moonPhaseFraction(date) 0..1 synodic phase fraction // moonPhaseFraction(date) 0..1 synodic phase fraction
// moonGlyph(p) phase fraction moon emoji // moonGlyph(p) phase fraction - moon emoji
// skyGradientForElev(e, isRising) solar elevation {top,bot} hex pair // skyGradientForElev(e, isRising) solar elevation - {top,bot} hex pair
// skyFillForElev(e, isRising) convenience single-colour sky fill // skyFillForElev(e, isRising) convenience single-colour sky fill
// grassFillForElev(e) solar elevation {top,bot} ground hex // grassFillForElev(e) solar elevation - {top,bot} ground hex
// ════════════════════════════════════════════════════════════════════════ // ------------------------------------------------------------------------
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// UTCI THERMAL STRESS BANDS the coloured pills in the table. // UTCI THERMAL STRESS BANDS - the coloured pills in the table.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// To recolour any band, change its bg/fg hex code. To shift the // To recolour any band, change its bg/fg hex code. To shift the
// boundary between bands (e.g. make "Comfortable" wider), change the // boundary between bands (e.g. make "Comfortable" wider), change the
// `if (u < …)` thresholds. Order matters they're checked top-down. // 'if (u < -)' thresholds. Order matters - they're checked top-down.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function utciCategory(u) { export function utciCategory(u) {
if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', fg: '#fff' }; if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', fg: '#fff' };
if (u < -27) return { label: 'Very strong cold', bg: '#23408f', fg: '#fff' }; if (u < -27) return { label: 'Very strong cold', bg: '#23408f', fg: '#fff' };
@@ -44,16 +44,16 @@ export function utciCategory(u) {
return { label: 'Extreme heat', bg: '#7a1a1a', fg: '#fff' }; return { label: 'Extreme heat', bg: '#7a1a1a', fg: '#fff' };
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// SOAK-FACTOR SunScope's original rain/snow penalty. // SOAK-FACTOR - SunScope's original rain/snow penalty.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// This is what makes "UTCI+P" different from plain UTCI. It subtracts // This is what makes "UTCI+P" different from plain UTCI. It subtracts
// extra felt-temperature for rain (wet clothing = evaporative chill) // extra felt-temperature for rain (wet clothing = evaporative chill)
// and snow (wet snow is brutal). Wind amplifies the rain penalty. // and snow (wet snow is brutal). Wind amplifies the rain penalty.
// Calibrated by feel adjust the multipliers if you find it too // Calibrated by feel - adjust the multipliers if you find it too
// strong/weak. The big number "7" caps the max rain penalty so a // strong/weak. The big number "7" caps the max rain penalty so a
// freak 50mm/h reading can't make UTCI nonsensical. // freak 50mm/h reading can't make UTCI nonsensical.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function precipPenalty(precipMm, snowCmH, windMs) { export function precipPenalty(precipMm, snowCmH, windMs) {
let penalty = 0; let penalty = 0;
if (precipMm > 0) { if (precipMm > 0) {
@@ -65,13 +65,13 @@ export function precipPenalty(precipMm, snowCmH, windMs) {
return -Math.round(penalty * 10) / 10; return -Math.round(penalty * 10) / 10;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// WIND COMPASS meteorological bearing (deg FROM) 8-point label. // WIND COMPASS - meteorological bearing (deg FROM) - 8-point label.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// 0/360 = wind FROM north. The pointer in WindVane should rotate so // 0/360 = wind FROM north. The pointer in WindVane should rotate so
// the arrow's tail points to this bearing (i.e. shows where the wind // the arrow's tail points to this bearing (i.e. shows where the wind
// comes from), matching how real weather vanes behave. // comes from), matching how real weather vanes behave.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function windCompass8(deg) { export function windCompass8(deg) {
if (deg == null || isNaN(deg)) return { label: '—', snapped: 0 }; if (deg == null || isNaN(deg)) return { label: '—', snapped: 0 };
const dirs = ['N','NE','E','SE','S','SW','W','NW']; const dirs = ['N','NE','E','SE','S','SW','W','NW'];
@@ -79,20 +79,20 @@ export function windCompass8(deg) {
return { label: dirs[idx], snapped: idx * 45 }; return { label: dirs[idx], snapped: idx * 45 };
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// UV-A / UV-B SPLIT (estimate, not measurement). // UV-A / UV-B SPLIT (estimate, not measurement).
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Open-Meteo gives a total erythemal UV index. UV-A reaches the // Open-Meteo gives a total erythemal UV index. UV-A reaches the
// surface much more reliably than UV-B; UV-B is far more sensitive // surface much more reliably than UV-B; UV-B is far more sensitive
// to solar elevation because of atmospheric path length. // to solar elevation because of atmospheric path length.
// //
// Cheap model: // Cheap model:
// At noon (sun overhead) the UV-B share of total UV index is ~15%, // At noon (sun overhead) the UV-B share of total UV index is ~15%,
// UV-A about 85%. Below ~10° solar elevation, UV-B falls off fast. // UV-A about 85%. Below ~10- solar elevation, UV-B falls off fast.
// We return two pseudo-"index" numbers so the columns are in the // We return two pseudo-"index" numbers so the columns are in the
// same units the user already understands. // same units the user already understands.
// Label these as estimates in the UI. // Label these as estimates in the UI.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function uvSplit(uv, elevDeg) { export function uvSplit(uv, elevDeg) {
if (!uv || uv <= 0 || elevDeg <= 0) return { uvA: 0, uvB: 0 }; if (!uv || uv <= 0 || elevDeg <= 0) return { uvA: 0, uvB: 0 };
const sinE = Math.sin(elevDeg * Math.PI / 180); const sinE = Math.sin(elevDeg * Math.PI / 180);
@@ -101,13 +101,13 @@ export function uvSplit(uv, elevDeg) {
return { uvA: uv * uvaFr, uvB: uv * uvbFr }; return { uvA: uv * uvaFr, uvB: uv * uvbFr };
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// SUNBURN TIME minutes to MED for the chosen Fitzpatrick skin type. // SUNBURN TIME - minutes to MED for the chosen Fitzpatrick skin type.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Standard erythemal model: time_min base_minutes[type] / UV_index. // Standard erythemal model: time_min - base_minutes[type] / UV_index.
// Numbers are the well-known "unprotected, midday, no sunscreen" // Numbers are the well-known "unprotected, midday, no sunscreen"
// reference times at UV = 1. Returns Infinity when UV is 0 (night). // reference times at UV = 1. Returns Infinity when UV is 0 (night).
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export const SKIN_TYPES = { export const SKIN_TYPES = {
I: { name: 'I · Very fair', base: 67 }, I: { name: 'I · Very fair', base: 67 },
II: { name: 'II · Fair', base: 100 }, II: { name: 'II · Fair', base: 100 },
@@ -128,21 +128,21 @@ export function burnLabel(mins) {
return `${Math.round(mins)}m`; return `${Math.round(mins)}m`;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// VEHICLE TYPES presets for the cabin heat model. // VEHICLE TYPES - presets for the cabin heat model.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Each entry tweaks the physical levers in calcVehicleInteriorTemp: // Each entry tweaks the physical levers in calcVehicleInteriorTemp:
// albedo how much solar the bodywork reflects (0 = black, 1 = mirror) // albedo - how much solar the bodywork reflects (0 = black, 1 = mirror)
// glazingArea relative sun-exposed glass area (1.0 = typical car) // glazingArea - relative sun-exposed glass area (1.0 = typical car)
// bodyU effective body/panel conductance into the cabin. Cars are // bodyU - effective body/panel conductance into the cabin. Cars are
// thin metal/glass boxes; motorhomes/caravans have insulated // thin metal/glass boxes; motorhomes/caravans have insulated
// sandwich panels, commonly around 2535 mm thick. // sandwich panels, commonly around 25-35 mm thick.
// hCabinLoss effective heat rejection/infiltration from the cabin air. // hCabinLoss - effective heat rejection/infiltration from the cabin air.
// thermalMass lower values mean the interior warms more slowly in the hour. // thermalMass - lower values mean the interior warms more slowly in the hour.
// retainedWarmth occupied insulated living spaces hold heat from previous // retainedWarmth - occupied insulated living spaces hold heat from previous
// hours, people, appliances, and background heating. // hours, people, appliances, and background heating.
// internalGain small living-space warmth boost when closed up. // internalGain - small living-space warmth boost when closed up.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export const VEHICLE_TYPES = { export const VEHICLE_TYPES = {
car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.0, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 }, car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.0, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.3, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 }, mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.3, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
@@ -151,32 +151,32 @@ export const VEHICLE_TYPES = {
caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 }, caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 },
}; };
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// BUILDING TYPES presets for the indoor temperature model. // BUILDING TYPES - presets for the indoor temperature model.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass. // Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass.
// //
// uWall W/m²K Effective envelope U-value. Higher = faster response // uWall W/m-K Effective envelope U-value. Higher = faster response
// to outdoor swings, less insulation. // to outdoor swings, less insulation.
// lagHours h Thermal mass time constant. Heavier construction = longer // lagHours h Thermal mass time constant. Heavier construction = longer
// lag before indoor temp follows outdoor changes. // lag before indoor temp follows outdoor changes.
// glazingRatio Fraction of floor area that is window. More glass = more // glazingRatio - Fraction of floor area that is window. More glass = more
// solar gain in summer, more heat loss in winter. // solar gain in summer, more heat loss in winter.
// gValue Solar heat gain coefficient of glazing. 0.63 = standard // gValue - Solar heat gain coefficient of glazing. 0.63 = standard
// double glazing; 0.3 = modern low-e triple. // double glazing; 0.3 = modern low-e triple.
// orientFactor Fraction of windows facing the sun at any given time. // orientFactor - Fraction of windows facing the sun at any given time.
// 0.5 = random orientation; 0.8 = south-facing conservatory. // 0.5 = random orientation; 0.8 = south-facing conservatory.
// curtainBlock Fraction of solar gain blocked when managed (curtains // curtainBlock - Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains 0.80; blinds 0.50. // closed). Thick lined curtains - 0.80; blinds - 0.50.
// ventAlpha Blending weight per hour when smart ventilation is open. // ventAlpha - Blending weight per hour when smart ventilation is open.
// Higher = more air changes per hour. // Higher = more air changes per hour.
// solarScale Converts effective window solar gain into an indoor // solarScale - Converts effective window solar gain into an indoor
// temperature lift. Lower values mean more thermal mass. // temperature lift. Lower values mean more thermal mass.
// baseTemp Occupied/retained warmth baseline for normal homes. // baseTemp - Occupied/retained warmth baseline for normal homes.
// internalGain Small heat gain from people, appliances, and background use. // internalGain - Small heat gain from people, appliances, and background use.
// retainedScale How strongly the building holds above-outdoor warmth in // retainedScale - How strongly the building holds above-outdoor warmth in
// cool conditions. Higher = better retained warmth. // cool conditions. Higher = better retained warmth.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export const BUILDING_TYPES = { 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 }, 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 }, 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 },
@@ -187,30 +187,30 @@ export const BUILDING_TYPES = {
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 }, 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 },
}; };
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// CLOUD CATEGORY pick one of 4 icon styles from low/mid/high split. // CLOUD CATEGORY - pick one of 4 icon styles from low/mid/high split.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Returns: 'clear' | 'wispy' | 'scattered' | 'overcast' // Returns: 'clear' | 'wispy' | 'scattered' | 'overcast'
// Uses total cover for headline level, but biases towards 'wispy' // Uses total cover for headline level, but biases towards 'wispy'
// when only high cloud is present (cirrus barely blocks the sun). // when only high cloud is present (cirrus barely blocks the sun).
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function cloudCategory(total, low, mid, high) { export function cloudCategory(total, low, mid, high) {
const t = total ?? 0; const t = total ?? 0;
const l = low ?? 0; const l = low ?? 0;
const m = mid ?? 0; const m = mid ?? 0;
const h = high ?? 0; const h = high ?? 0;
if (t < 10) return 'clear'; if (t < 10) return 'clear';
// Mostly high cloud with little low/mid wispy regardless of % total // Mostly high cloud with little low/mid - wispy regardless of % total
if (h > 40 && l < 25 && m < 25) return 'wispy'; if (h > 40 && l < 25 && m < 25) return 'wispy';
if (t < 40) return 'wispy'; if (t < 40) return 'wispy';
if (t < 75) return 'scattered'; if (t < 75) return 'scattered';
return 'overcast'; return 'overcast';
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// CONFIDENCE BANDS smooth high-noon sunset gradient on day tabs. // CONFIDENCE BANDS - smooth high-noon - sunset gradient on day tabs.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// `i` is the day index (0 = today, 13 = day 14). // 'i' is the day index (0 = today, 13 = day 14).
// //
// Each day gets its own shade, interpolated between two endpoint // Each day gets its own shade, interpolated between two endpoint
// colours. To re-skin the gradient (say, blue-to-purple instead of // colours. To re-skin the gradient (say, blue-to-purple instead of
@@ -224,12 +224,12 @@ export function cloudCategory(total, low, mid, high) {
// The label switches in 4 stages so users still see a friendly // The label switches in 4 stages so users still see a friendly
// description ("you're in the trustworthy zone" vs "this is an // description ("you're in the trustworthy zone" vs "this is an
// outlook"). The tab background itself flows smoothly day to day. // outlook"). The tab background itself flows smoothly day to day.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function confidenceBand(i) { export function confidenceBand(i) {
// Position along the gradient: 0 on day 0, 1 on day 13. // Position along the gradient: 0 on day 0, 1 on day 13.
const t = Math.min(1, Math.max(0, i / 13)); const t = Math.min(1, Math.max(0, i / 13));
// ENDPOINTS change these four RGB arrays to re-skin the gradient. // ENDPOINTS - change these four RGB arrays to re-skin the gradient.
const bgStart = [255, 247, 214]; // #fff7d6 pale yellow (high noon) const bgStart = [255, 247, 214]; // #fff7d6 pale yellow (high noon)
const bgEnd = [232, 152, 104]; // #e89868 terracotta (sunset) const bgEnd = [232, 152, 104]; // #e89868 terracotta (sunset)
const edgeStart = [245, 231, 161]; // #f5e7a1 soft golden const edgeStart = [245, 231, 161]; // #f5e7a1 soft golden
@@ -246,24 +246,24 @@ export function confidenceBand(i) {
bg: `rgb(${br}, ${bg}, ${bb})`, bg: `rgb(${br}, ${bg}, ${bb})`,
edge: `rgb(${er}, ${eg}, ${eb})`, edge: `rgb(${er}, ${eg}, ${eb})`,
tint: `rgba(${er}, ${eg}, ${eb}, 0.22)`, tint: `rgba(${er}, ${eg}, ${eb}, 0.22)`,
// Qualitative confidence label (camera-focus metaphor // Qualitative confidence label (camera-focus metaphor -
// on-brand for SunScope, and instantly readable). // on-brand for SunScope, and instantly readable).
label: i < 3 ? 'Pin-sharp' // days 13 highest skill label: i < 3 ? 'Pin-sharp' // days 1-3 highest skill
: i < 7 ? 'Sharp' // days 47 solid : i < 7 ? 'Sharp' // days 4-7 solid
: i < 10 ? 'Soft focus' // days 810 trends only : i < 10 ? 'Soft focus' // days 8-10 trends only
: 'Blurry', // days 1114 outlook only : 'Blurry', // days 11-14 outlook only
}; };
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// MOON PHASE works out which moon emoji to show on night hours. // MOON PHASE - works out which moon emoji to show on night hours.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Returns a fraction 0..1: // Returns a fraction 0..1:
// 0.00 = new moon 0.50 = full moon // 0.00 = new moon 0.50 = full moon
// 0.25 = first quarter 0.75 = last quarter // 0.25 = first quarter 0.75 = last quarter
// The maths is a simple synodic-period calculation referenced from // The maths is a simple synodic-period calculation referenced from
// a known new moon (6 Jan 2000). Accurate to within a few hours. // a known new moon (6 Jan 2000). Accurate to within a few hours.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function moonPhaseFraction(date) { export function moonPhaseFraction(date) {
const JD = date.getTime() / 86400000 + 2440587.5; const JD = date.getTime() / 86400000 + 2440587.5;
const syn = 29.530588; const syn = 29.530588;
@@ -277,23 +277,23 @@ export function moonGlyph(p) {
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p * 8 + 0.5) % 8]; return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p * 8 + 0.5) % 8];
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// SKY GRADIENT top/bottom colour pair for the SkyScope disk gradient. // SKY GRADIENT - top/bottom colour pair for the SkyScope disk gradient.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// isRising: true when sun is climbing (local hour < 12). // isRising: true when sun is climbing (local hour < 12).
// //
// Sunrise palette reds, oranges, yellows at the horizon. // Sunrise palette - reds, oranges, yellows at the horizon.
// Sunset palette pinks, purples, mauves at the horizon. // Sunset palette - pinks, purples, mauves at the horizon.
// Both share the same deep blue zenith at high elevations. // Both share the same deep blue zenith at high elevations.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// Sky colour keyframes [elevation, topHex, botHex] for rising and setting sun. // Sky colour keyframes - [elevation, topHex, botHex] for rising and setting sun.
// Colours are smoothly interpolated between adjacent keyframes. // Colours are smoothly interpolated between adjacent keyframes.
const SKY_KEYS_RISING = [ const SKY_KEYS_RISING = [
[-18, '#0e0c28', '#1a1538'], // deep night [-18, '#0e0c28', '#1a1538'], // deep night
[-14, '#1e1a50', '#3a2f60'], // nautical twilight [-14, '#1e1a50', '#3a2f60'], // nautical twilight
[ -8, '#2a3a6a', '#a04828'], // pre-dawn: indigo burnt sienna [ -8, '#2a3a6a', '#a04828'], // pre-dawn: indigo - burnt sienna
[ -3, '#4a7aaa', '#e8622a'], // horizon band: deep blue vivid red-orange [ -3, '#4a7aaa', '#e8622a'], // horizon band: deep blue - vivid red-orange
[ 3, '#6aaddb', '#ffb347'], // golden hour: blue warm amber [ 3, '#6aaddb', '#ffb347'], // golden hour: blue - warm amber
[ 10, '#7cc5ec', '#c8e3ee'], // low sun: pale blue sky [ 10, '#7cc5ec', '#c8e3ee'], // low sun: pale blue sky
[ 30, '#5bb8e8', '#9fd3ef'], // mid-day blue [ 30, '#5bb8e8', '#9fd3ef'], // mid-day blue
[ 60, '#2e8fd4', '#7cc8ef'], // high noon: rich deep blue [ 60, '#2e8fd4', '#7cc8ef'], // high noon: rich deep blue
@@ -302,9 +302,9 @@ const SKY_KEYS_RISING = [
const SKY_KEYS_SETTING = [ const SKY_KEYS_SETTING = [
[-18, '#0e0c28', '#1a1538'], [-18, '#0e0c28', '#1a1538'],
[-14, '#1e1a50', '#3a2f60'], [-14, '#1e1a50', '#3a2f60'],
[ -8, '#5a3572', '#c07080'], // dusk: purple dusty rose/mauve [ -8, '#5a3572', '#c07080'], // dusk: purple - dusty rose/mauve
[ -3, '#7a5090', '#e8826a'], // horizon band: violet coral/pink [ -3, '#7a5090', '#e8826a'], // horizon band: violet - coral/pink
[ 3, '#7b8fc4', '#ffb877'], // golden hour: blue-violet golden [ 3, '#7b8fc4', '#ffb877'], // golden hour: blue-violet - golden
[ 10, '#7cc5ec', '#c8e3ee'], [ 10, '#7cc5ec', '#c8e3ee'],
[ 30, '#5bb8e8', '#9fd3ef'], [ 30, '#5bb8e8', '#9fd3ef'],
[ 60, '#2e8fd4', '#7cc8ef'], [ 60, '#2e8fd4', '#7cc8ef'],
@@ -344,20 +344,20 @@ export function skyGradientForElev(e, isRising) {
return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e); return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
} }
// ─── skyFillForElev ──────────────────────────────────────────────────────── // --- skyFillForElev --------------------------------------------------------
// Convenience single-colour fill for contexts that don't need a gradient // Convenience single-colour fill for contexts that don't need a gradient
// (e.g. solid background chips). Returns the horizon (bottom) colour. // (e.g. solid background chips). Returns the horizon (bottom) colour.
export function skyFillForElev(e, isRising = false) { export function skyFillForElev(e, isRising = false) {
return skyGradientForElev(e, isRising).bot; return skyGradientForElev(e, isRising).bot;
} }
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
// GRASS FILL ground-strip colour keyed to solar elevation. // GRASS FILL - ground-strip colour keyed to solar elevation.
// ─────────────────────────────────────────────────────────────────── // -------------------------------------------------------------------
// Mirrors the sky palette so the SkyScope disk reads naturally: vivid // Mirrors the sky palette so the SkyScope disk reads naturally: vivid
// green at noon, amber at golden hour, purple-dark at twilight/night. // green at noon, amber at golden hour, purple-dark at twilight/night.
// Returns {top, bot} for a subtle two-stop ground gradient. // Returns {top, bot} for a subtle two-stop ground gradient.
// ═══════════════════════════════════════════════════════════════════ // -------------------------------------------------------------------
export function grassFillForElev(e) { export function grassFillForElev(e) {
if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday