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.)
// Current active filter profile
const [activeProfile, setActiveProfile] = useState('basic');
// Current active filter profile - persisted in localStorage
const [activeProfile, setActiveProfile] = useState(() => {
try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
});
// Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see)
// 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] }));
// 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.
const [vehicleType, setVehicleType] = useState('car');
// Places profile sub-variant (urban / beach / events / festival / wintersports / naturist)
const [outdoorsVariant, setOutdoorsVariant] = useState('urban');
// Places profile sub-variant - persisted in localStorage
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).
const [vehicleVent, setVehicleVent] = useState(false);
@@ -287,7 +300,13 @@ export function UTCIForecast() {
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
const [buildingType, setBuildingType] = useState('brick');
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.
const [pollenType, setPollenType] = useState(() => {
@@ -304,6 +323,7 @@ export function UTCIForecast() {
const activateProfile = (key) => {
const profile = FILTER_PROFILES[key];
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
setActiveProfile(key);
if (key !== 'custom') {
setVisibleCols({ ...profile.cols });
@@ -544,7 +564,7 @@ export function UTCIForecast() {
</nav>
<main class="utci-shell">
<!-- ── EVENT BANNER ── slideshow when multiple events active ── -->
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
${activeEvents.length > 0 && (() => {
const ev = activeEvents[bannerIndex] || activeEvents[0];
@@ -626,24 +646,7 @@ export function UTCIForecast() {
glob=${currentRow?.glob ?? 0}
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 class="header-right">
@@ -694,12 +697,7 @@ export function UTCIForecast() {
${forecast && days.length > 0 && html`
<${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' : ''}`}>
<button
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
@@ -803,13 +801,7 @@ export function UTCIForecast() {
</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] && (() => {
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
@@ -1001,13 +993,7 @@ export function UTCIForecast() {
</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' : '' }}>
<span class="filter-profiles-label">Profile:</span>
${profileButtonOrder.slice(0, 3).map((key) => {
@@ -1048,8 +1034,9 @@ export function UTCIForecast() {
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariant(v);
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
@@ -1077,8 +1064,9 @@ export function UTCIForecast() {
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariant(v);
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
@@ -1108,12 +1096,7 @@ export function UTCIForecast() {
</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`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
@@ -1244,68 +1227,13 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</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}>
<span class="utci-scroll-chevron left" 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-track" ref=${headTrackRef}>
<table class="utci-table utci-table-head" ref=${headTableRef}>
@@ -1344,9 +1272,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</table>
</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}>
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
<tbody>
@@ -1655,10 +1581,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</div>
</div>
<!--
ALMANAC — upcoming cosmic events in the next 3 months.
Location-aware: visibility notes adjust by latitude.
-->
${(() => {
const upcoming = getUpcomingEvents(location, 90);
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.
//
// Exports (in order of appearance):
@@ -13,7 +13,7 @@
// ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent })
// large UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
import { h, Fragment } from '../vendor/preact.js';
import { useState, useEffect, useRef } from '../vendor/preact-hooks.js';
@@ -26,19 +26,19 @@ import { getLensOverlaySVG } from './events.js';
const html = htm.bind(h);
// ═══════════════════════════════════════════════════════════════════
// CUSTOMSELECT cross-platform styled dropdown pill.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// CUSTOMSELECT - cross-platform styled dropdown pill.
// -------------------------------------------------------------------
// props:
// value current value string
// options array of { value, label, disabled }
// onChange fn(newValue)
// hideLabel label shown when value === 'off' (e.g. 'Burn')
// hidingLabel label shown for the 'off' option when active (e.g. 'Hide Burn')
// isOn bool whether the pill shows as active (brass)
// groupedLeft bool fuse right side with a VentPill
// isLastChild bool restore right border-radius when no sibling follows
// ═══════════════════════════════════════════════════════════════════
// value - current value string
// options - array of { value, label, disabled }
// onChange - fn(newValue)
// hideLabel - label shown when value === 'off' (e.g. 'Burn')
// hidingLabel - label shown for the 'off' option when active (e.g. 'Hide Burn')
// isOn - bool - whether the pill shows as active (brass)
// groupedLeft - bool - fuse right side with a VentPill
// isLastChild - bool - restore right border-radius when no sibling follows
// -------------------------------------------------------------------
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef(null);
@@ -111,15 +111,15 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
</span>`;
}
// ═══════════════════════════════════════════════════════════════════
// VENTPILL the checkbox pill fused to the right of a CustomSelect.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// VENTPILL - the checkbox pill fused to the right of a CustomSelect.
// -------------------------------------------------------------------
// props:
// checked bool
// onChange fn()
// label string
// title tooltip string
// ═══════════════════════════════════════════════════════════════════
// checked - bool
// onChange - fn()
// label - string
// title - tooltip string
// -------------------------------------------------------------------
export function VentPill({ checked, onChange, label, title }) {
return html`
<label class=${`cs-vent${checked ? ' on' : ''}`} title=${title}>
@@ -134,23 +134,23 @@ export function VentPill({ checked, onChange, label, title }) {
</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):
// 1. Sky disk colour from skyFillForElev() above
// 2. Horizon line faint dashed line at the middle
// 3. Sun OR moon sun positioned vertically by elevation;
// 1. Sky disk - colour from skyFillForElev() above
// 2. Horizon line - faint dashed line at the middle
// 3. Sun OR moon - sun positioned vertically by elevation;
// moon shows real phase (synodic period)
// 4. Brass ring the telescope/scope edge (#c8922a)
// 5. Lens highlight subtle inner glint
// 4. Brass ring - the telescope/scope edge (#c8922a)
// 5. Lens highlight - subtle inner glint
//
// Tweakable bits inside this function:
// size pass a different size= when calling for bigger/smaller
// innerR how thick the brass ring looks
// sunR the sun's drawn radius
// the stroke colour #c8922a is the brass change for a different metal
// ═══════════════════════════════════════════════════════════════════
// - size - pass a different size= when calling for bigger/smaller
// - innerR - how thick the brass ring looks
// - sunR - the sun's drawn radius
// - the stroke colour #c8922a is the brass - change for a different metal
// -------------------------------------------------------------------
export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const r = size / 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 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 sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
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.
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 glowR = sunR + 4 + radFrac * 6; // total glow radius
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>`;
}
// ═══════════════════════════════════════════════════════════════════
// WINDVANE clean compass arrow on transparent background.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// WINDVANE - clean compass arrow on transparent background.
// -------------------------------------------------------------------
// 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:
// bearing degrees, 0 = wind FROM north
// size pixel diameter (default 30 to match SkyScope)
// bearing - degrees, 0 = wind FROM north
// size - pixel diameter (default 30 to match SkyScope)
//
// Tweakable bits:
// arrowColor / nMarkerColor line/fill colours
// Tiny N letter sits just above the arrow tail for orientation
// ═══════════════════════════════════════════════════════════════════
// - arrowColor / nMarkerColor - line/fill colours
// - Tiny N letter sits just above the arrow tail for orientation
// -------------------------------------------------------------------
export function WindVane({ bearing, size = 30 }) {
if (bearing == null || isNaN(bearing)) {
return html`<span style=${{ opacity: 0.5, fontSize: '11px' }}>—</span>`;
@@ -280,7 +280,7 @@ export function WindVane({ bearing, size = 30 }) {
const nColor = '#9a7d5a';
// Geometry of the single-ended arrow (drawn pointing DOWN by default).
// 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 headBase = r * 0.58; // bottom of the triangle head
const headW = r * 0.42;
@@ -311,14 +311,14 @@ export function WindVane({ bearing, size = 30 }) {
</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.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// category: 'clear' | 'wispy' | 'scattered' | 'overcast'
// elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
const r = size / 2;
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)}
</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 rays = Array.from({ length: 8 }, (_, k) => {
const angle = (k * Math.PI) / 4;
@@ -382,23 +382,23 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
</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
// needle pointing at the current felt-temperature.
//
// Tweakable bits:
// R = 86 outer ring radius (changes overall size)
// cx, cy = 100 centre point (leave alone unless you also
// - R = 86 outer ring radius (changes overall size)
// - cx, cy = 100 centre point (leave alone unless you also
// change the viewBox="0 0 200 200" below)
// { length: 36 } number of tick marks (1 every 10°)
// stressBands[] the colour ramp around the rim (matches UTCI)
// (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep
// - { length: 36 } number of tick marks (1 every 10-)
// - stressBands[] the colour ramp around the rim (matches UTCI)
// - (value + 10) / 60 maps UTCI -10..50 onto the 270- sweep -
// widen the dial range by changing those numbers
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
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,
// grass on the bottom, sun positioned by elevation (or moon at night
// 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 sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
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.
const radFrac = Math.min(1, (glob || 0) / 900);
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>`;
}
// ═══════════════════════════════════════════════════════════════════
// PRECIPICON rain drop or snowflake SVG for the precip cell.
// ───────────────────────────────────────────────────────────────────
// precip mm/h rainfall
// snow cm/h snowfall
// size pixel size (default 28 to match CloudIcon)
// -------------------------------------------------------------------
// PRECIPICON - rain drop or snowflake SVG for the precip cell.
// -------------------------------------------------------------------
// precip - mm/h rainfall
// snow - cm/h snowfall
// size - pixel size (default 28 to match CloudIcon)
//
// Intensity bands:
// dry faint dash
// light rain (< 1 mm/h) 1 drop
// moderate rain(< 4 mm/h) 2 drops
// heavy rain ( 4 mm/h) 3 drops
// light snow (< 0.5cm/h) 1 flake
// heavy snow ( 0.5cm/h) 2 flakes
// mixed 1 drop + 1 flake
// ═══════════════════════════════════════════════════════════════════
// dry - faint dash
// light rain (< 1 mm/h) - 1 drop
// moderate rain(< 4 mm/h) - 2 drops
// heavy rain (- 4 mm/h) - 3 drops
// light snow (< 0.5cm/h) - 1 flake
// heavy snow (- 0.5cm/h) - 2 flakes
// mixed - 1 drop + 1 flake
// -------------------------------------------------------------------
export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
const r = size / 2;
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} />
</g>`;
// Dry just a faint dash
// Dry - just a faint dash
if (!hasRain && !hasSnow) {
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
@@ -683,9 +683,9 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
</svg>`;
}
// Rain intensity number of drops
// Rain intensity - number of drops
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 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,
// vehicleVent, buildingType) and get back { hourlyRows, days, utcOffsetMs }.
//
// 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:
// String slices (iso.slice(...)) for display & day grouping
// A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*).
// ════════════════════════════════════════════════════════════════════════
// "2026-05-13T14:00" - no Z suffix. Two forms are used in each row:
// - String slices (iso.slice(...)) for display & day grouping
// - A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*).
// ------------------------------------------------------------------------
import {
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 }) {
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
// "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
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 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.
// 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.
// Diffuse (scattered light through cloud) contributes ~20% as much heat to
// a surface as direct beam, so we weight it accordingly.
const effectiveRad = dir + dif * 0.2;
const concreteT = calcConcreteTemp(Ta, effectiveRad, va);
// 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
// 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 elev = solarElevationDeg(location.lat, location.lon, dt);
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 { uvA, uvB } = uvSplit(uv, elev);
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 aqi = getAq('european_aqi', 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 olivePollen = getAq('olive_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
// intentionally kept SEPARATE from the core columns above so that baseline
// 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):
// Resting Walking Cycling Running Sport/Intense
// Resting - Walking - Cycling - Running - Sport/Intense
//
// 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:
// metabolic heat raises core body temp
// sweating washes away sunscreen faster
// more skin blood flow = higher UV sensitivity
// - metabolic heat raises core body temp
// - sweating washes away sunscreen faster
// - more skin blood flow = higher UV sensitivity
// Suggested multipliers (tune with real data):
// Resting: 1.0 (no change)
// Walking: 0.85
@@ -110,22 +110,22 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// Running: 0.60
// 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
// 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.
// Colour code in the UI: 🟢 🟡 🟠 🔴
// Colour code in the UI: - - - -
//
// 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.
// 2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level).
// 3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier
// 4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels.
// 5. Add both fields to the returned row object below.
// 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 {
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);
});
// ── 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
// 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.
//
// WHAT TO COMPUTE (per day, from that day's rows):
// Peak UTCI+P and time it occurs heat stress headline
// Min UTCI+P and time cold stress headline
// Peak UV index and time UV warning
// Max precipitation rate and time rain/ice warning
// Max vehicle cabin temp "dangerous to leave pets/children in car"
// Max pollen level + type pollen advisory
// Road condition risk (low air temp + precip ice risk)
// - Peak UTCI+P and time it occurs - heat stress headline
// - Min UTCI+P and time - cold stress headline
// - Peak UV index and time - UV warning
// - Max precipitation rate and time - rain/ice warning
// - Max vehicle cabin temp - "dangerous to leave pets/children in car"
// - Max pollen level + type - pollen advisory
// - Road condition risk (low air temp + precip - ice risk)
//
// ALERT CATEGORIES (each generates a styled warning card if threshold exceeded):
// 🌡️ Heat warning UTCI+P > 32°C
// 🥶 Cold warning UTCI+P < 0°C
// ☀️ UV warning UV index > 6
// 🌧️ Heavy rain precip > 4mm/h
// 🧊 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")
// 🌿 High pollen any pollen type > 50 grains/m³
// -- Heat warning UTCI+P > 32-C
// - Cold warning UTCI+P < 0-C
// -- UV warning UV index > 6
// -- Heavy rain precip > 4mm/h
// - 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")
// - High pollen any pollen type > 50 grains/m-
//
// DESIGN NOTES:
// Cards should be concise one line of bold text + a short explanation
// Colour-coded to match the existing UTCI stress band palette
// 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
// The "X°C above seasonal norm" historical context line (see app.js comment)
// - Cards should be concise - one line of bold text + a short explanation
// - Colour-coded to match the existing UTCI stress band palette
// - 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
// - 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
//
// 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))
//
// where buildDaySummary() is a new helper in this file (or a separate
// summary.js module if it grows large).
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
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.
// 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).
//
// Where to find things:
@@ -15,13 +15,13 @@
// activityVariantKeys ........ which variants appear in the Activities menu
// placeVariantKeys ........... which variants appear in the Places menu
// 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.
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.
export const FILTER_PROFILES = {
basic: {
@@ -61,37 +61,37 @@ export const FILTER_PROFILES = {
icon: '⚙️',
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 },
// ── 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:
//
// CREATE named profiles give them a name and icon, choose any columns
// SAVE multiple profiles stored in localStorage (or user account if we
// - CREATE named profiles - give them a name and icon, choose any columns
// - SAVE multiple profiles - stored in localStorage (or user account if we
// 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.
// The existing col visibility flags stay, reordering is a separate
// `colOrder: ['hour', 'air', 'wind', ...]` array on the profile object.
// DELETE / RENAME profiles in a small management modal
// 'colOrder: ['hour', 'air', 'wind', ...]' array on the profile object.
// - DELETE / RENAME profiles in a small management modal
//
// The 'Custom' button becomes " New Profile" when in profile-creation mode.
// Existing preset profiles (Basic, Home, Vehicle) remain read-only.
// The 'Custom' button becomes "- New Profile" when in profile-creation mode.
// Existing preset profiles (Basic, Home, Vehicle-) remain read-only.
//
// Data shape per saved profile:
// {
// id: 'profile_abc123', // generated uuid
// label: 'My Beach Days',
// icon: '🏖️', // user picks from a small emoji set
// icon: '--', // user picks from a small emoji set
// cols: { ...standard col flags },
// colOrder: ['hour', 'air', 'burn', 'wind', ...], // user's preferred order
// createdAt: ISO string,
// }
//
// 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.
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 } },
@@ -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 } },
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 } },
// 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 } },
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 } },
// 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 } },
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 } },
// 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 } },
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 } },
+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
// under ./events/ and exposes the same API as before, so callers
// (app.js, components.js) keep working without changes.
//
// 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)
// 3. Weather-derived events (stargazing, sunset, heat spike, storm)
//
@@ -24,7 +24,7 @@
// Weather check functions ..... ./events/weather-checks.js
// Dynamic message helper ...... ./events/dynamic-message.js
// Lens overlay SVG renderer ... ./events/lens-overlay.js
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
import { COSMIC_CALENDAR } from './events/cosmic-calendar.js';
import {
@@ -40,16 +40,16 @@ import { dynamicCosmicMessage } from './events/dynamic-message.js';
export { getUpcomingEvents } from './events/almanac-calendar.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.
// Leave as null for automatic event detection.
//
// Example:
// export const PROMO_OVERRIDE = {
// id: 'promo-summer',
// emoji: '☀️',
// emoji: '--',
// title: 'Summer Sale',
// message: 'SunScope Extra 20% off this weekend only.',
// message: 'SunScope Extra - 20% off this weekend only.',
// color: '#c8922a',
// textColor: '#fff',
// type: 'promo',
@@ -57,7 +57,7 @@ export { getLensOverlaySVG } from './events/lens-overlay.js';
//
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.
export function getCellTagEvents(events, row) {
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.
// Empty array = nothing active.
//
@@ -83,10 +83,10 @@ export function getCellTagEvents(events, row) {
// 3. All matching weather-derived events
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];
// 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)
? rows[0].iso.slice(0, 10)
: new Date().toISOString().slice(0, 10);
@@ -110,7 +110,7 @@ export function getActiveEvents(rows, location) {
return [...cosmicHits, ...weatherEvents];
}
// ─── LENS EVENT PICKER ───────────────────────────────────────────────────
// --- LENS EVENT PICKER ---------------------------------------------------
// 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.
// 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.
//
// Includes all events from COSMIC_CALENDAR plus multi-year entries.
// Each entry has a `latHint`:
// Each entry has a 'latHint':
// null = global / no hint
// 'north' = better from northern latitudes
// 'south' = better from southern latitudes
// 'path:...' = specific eclipse path note
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
export const ALMANAC_CALENDAR = [
// 2026 events (covers rest of year from today)
@@ -133,7 +133,7 @@ export const ALMANAC_CALENDAR = [
type: 'meteor',
},
// ── 2027 ─────────────────────────────────────────────────────────────
// -- 2027 -------------------------------------------------------------
{
id: 'quadrantids-2027',
emoji: '☄️',
@@ -276,8 +276,8 @@ export function getVisibilityNote(entry, lat) {
return null;
}
// ─── MAIN ALMANAC EXPORT ─────────────────────────────────────────────────
// Returns events with peak dates in the next `days` days (default 90),
// --- MAIN ALMANAC EXPORT -------------------------------------------------
// Returns events with peak dates in the next 'days' days (default 90),
// sorted by peak date, with a visibility note added.
export function getUpcomingEvents(location, days = 90) {
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.
//
// Each entry: { id, start: 'YYYY-MM-DD', end: 'YYYY-MM-DD',
@@ -7,11 +7,11 @@
// Active if today falls within [start, end] (inclusive).
// Night-only events (meteor showers, eclipses) are marked nightOnly: true
// so the cell icons only appear in night-time rows.
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
export const COSMIC_CALENDAR = [
// ── METEOR SHOWERS ──────────────────────────────────────────────────
// -- METEOR SHOWERS --------------------------------------------------
{
id: 'quadrantids-2026',
emoji: '☄️',
@@ -101,7 +101,7 @@ export const COSMIC_CALENDAR = [
start: '2026-12-17', end: '2026-12-26', peak: '2026-12-22',
},
// ── ECLIPSES ────────────────────────────────────────────────────────
// -- ECLIPSES --------------------------------------------------------
{
id: 'solar-eclipse-2026-aug',
emoji: '🌑',
@@ -125,7 +125,7 @@ export const COSMIC_CALENDAR = [
start: '2026-03-03', end: '2026-03-03',
},
// ── PLANETARY EVENTS ────────────────────────────────────────────────
// -- PLANETARY EVENTS ------------------------------------------------
{
id: 'saturn-opposition-2026',
emoji: '🪐',
@@ -160,7 +160,7 @@ export const COSMIC_CALENDAR = [
start: '2026-06-30', end: '2026-07-02',
},
// ── 2027 METEOR SHOWERS ─────────────────────────────────────────────
// -- 2027 METEOR SHOWERS ---------------------------------------------
{
id: 'quadrantids-2027',
emoji: '☄️',
@@ -234,7 +234,7 @@ export const COSMIC_CALENDAR = [
start: '2027-12-17', end: '2027-12-26', peak: '2027-12-22',
},
// ── 2027 ECLIPSES ───────────────────────────────────────────────────
// -- 2027 ECLIPSES ---------------------------------------------------
{
id: 'annular-solar-eclipse-2027-feb',
emoji: '🌑',
@@ -254,7 +254,7 @@ export const COSMIC_CALENDAR = [
start: '2027-08-02', end: '2027-08-02', peak: '2027-08-02',
},
// ── 2027 PLANETARY EVENTS ───────────────────────────────────────────
// -- 2027 PLANETARY EVENTS -------------------------------------------
{
id: 'mars-opposition-2027',
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.
//
// Before peak : "is active and building peak on <date>. <detail>"
// On peak ±1d : "peaks tonight <detail>"
// After peak : "is past its peak (<date>) but still possibly visible <detail>"
// Before peak : "is active and building - peak on <date>. <detail>"
// On peak -1d : "peaks tonight - <detail>"
// After peak : "is past its peak (<date>) but still possibly visible - <detail>"
//
// Single-day events (start === end) keep their static message unchanged.
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
export function dynamicCosmicMessage(ev, dateStr) {
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.
//
// Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers.
// 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
// string. Return null when no overlay applies.
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
export function getLensOverlaySVG(event, cx, cy, lensR) {
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.
// 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
// below, export it, and add it to the checks[] array inside
// getActiveEvents() (in events.js).
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
export function checkStargazing(rows) {
// Great stargazing: mostly clear night hours with low cloud
@@ -31,7 +31,7 @@ export function checkStargazing(rows) {
export function checkSunset(rows, location) {
// 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).
const twilightIndices = rows
.map((r, i) => ({ r, i }))
@@ -52,11 +52,11 @@ export function checkSunset(rows, location) {
runs.push(run);
// 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 sunsetRows = sunsetRun.map(({ r }) => r);
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;
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:
// click an anchor toggle the popup open
// hover an anchor for N ms open
// move into the popup keep it open
// leave the popup close after 200 ms
// click outside / scroll / resize close immediately
// - click an anchor - toggle the popup open
// - hover an anchor for N ms - open
// - move into the popup - keep it open
// - leave the popup - close after 200 ms
// - click outside / scroll / resize - close immediately
//
// 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.
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
export function useColumnPopup() {
// ─── Column header popup ────────────────────────────────────────────
// --- Column header popup --------------------------------------------
const [colPopup, setColPopup] = useState(null);
const colPopupRef = useRef(null);
const colPopupThRef = useRef(null);
const hoverTimerRef = useRef(null);
const closeTimerRef = useRef(null);
// ─── Event tag popup ────────────────────────────────────────────────
// --- Event tag popup ------------------------------------------------
// Holds { events[], slideIndex, x, y, arrowLeft, below }
// When multiple events are in the popup they auto-cycle with a crossfade.
const [eventTagPopup, setEventTagPopup] = useState(null);
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 prevSlideIndexRef = useRef(0);
const eventTagPopupRef = useRef(null);
@@ -76,7 +76,7 @@ export function useColumnPopup() {
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.
const handleThLeave = () => {
clearTimeout(hoverTimerRef.current);
@@ -103,7 +103,7 @@ export function useColumnPopup() {
};
}, [colPopup]);
// ─── Event tag popup helpers ─────────────────────────────────────────
// --- Event tag popup helpers -----------------------------------------
const calcEventPopupPos = (spanEl) => {
const rect = spanEl.getBoundingClientRect();
const popupW = 260, popupH = 80, margin = 8, gap = 6;
@@ -116,7 +116,7 @@ export function useColumnPopup() {
return { x, y, arrowLeft, below };
};
// ─── Slideshow advance ───────────────────────────────────────────────
// --- Slideshow advance -----------------------------------------------
// evSlideTo triggers a crossfade to a new slide index.
const evSlideTo = useCallback((nextIndex) => {
setEvTransition('exiting');
@@ -148,7 +148,7 @@ export function useColumnPopup() {
return () => clearInterval(evSlideTimerRef.current);
}, [eventTagPopup, evSlideTo]);
// ─── Event tag popup handlers ────────────────────────────────────────
// --- Event tag popup handlers ----------------------------------------
const openEventTagPopup = (events, spanEl) => {
clearInterval(evSlideTimerRef.current);
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.
//
// Responsibilities:
@@ -11,15 +11,15 @@
// stale.
//
// Inputs:
// location { lat, lon, name, country }
// location - { lat, lon, name, country }
//
// Outputs:
// forecast raw /v1/forecast response, or null
// airQuality raw /v1/air-quality response, or null
// loading true while the forecast fetch is in flight
// error fetch error message, or null
// now Date that ticks every 5 minutes (drives the "current row")
// ════════════════════════════════════════════════════════════════════════
// forecast - raw /v1/forecast response, or null
// airQuality - raw /v1/air-quality response, or null
// loading - true while the forecast fetch is in flight
// error - fetch error message, or null
// now - Date that ticks every 5 minutes (drives the "current row")
// ------------------------------------------------------------------------
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).
//
// 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.
// 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.
// 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-
// step with body cell widths even as columns toggle / window resizes.
//
@@ -15,12 +15,12 @@
// refs: { headTableRef, bodyTableRef, bodyScrollRef, headTrackRef }
// deps: { forecast, visibleCols, selectedDay, skinType, vehicleType,
// indoorMode, indoorManaged }
// anything that should cause a re-sync when it changes.
// - anything that should cause a re-sync when it changes.
//
// Outputs:
// tableCanScrollLeft, tableCanScrollRight drive the fade/chevron CSS
// handleBodyScroll attach to body onScroll
// ════════════════════════════════════════════════════════════════════════
// tableCanScrollLeft, tableCanScrollRight - drive the fade/chevron CSS
// handleBodyScroll - attach to body onScroll
// ------------------------------------------------------------------------
import { useState, useEffect, useLayoutEffect } from '../../vendor/preact-hooks.js';
@@ -37,7 +37,7 @@ export function useTableScroll({
indoorMode,
indoorManaged,
}) {
// ─── SCROLL INDICATORS ─────────────────────────────────────────────
// --- SCROLL INDICATORS ---------------------------------------------
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
const [tableCanScrollRight, setTableCanScrollRight] = useState(false);
@@ -48,7 +48,7 @@ export function useTableScroll({
setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
// ─── DRAG-TO-SCROLL ────────────────────────────────────────────────
// --- DRAG-TO-SCROLL ------------------------------------------------
useEffect(() => {
const el = bodyScrollRef.current;
if (!el) return;
@@ -103,7 +103,7 @@ export function useTableScroll({
updateTableScrollIndicators();
}, [forecast, visibleCols, selectedDay]);
// ─── BODY SCROLL HANDLER (called from JSX onScroll) ───────────────
// --- BODY SCROLL HANDLER (called from JSX onScroll) ---------------
const handleBodyScroll = () => {
const track = headTrackRef.current;
const body = bodyScrollRef.current;
@@ -112,7 +112,7 @@ export function useTableScroll({
updateTableScrollIndicators();
};
// ─── COLUMN-WIDTH SCROLL SYNC (layout effect) ─────────────────────
// --- COLUMN-WIDTH SCROLL SYNC (layout effect) ---------------------
// Synchronise the head and body table column widths with a
// "shrink-to-fit then distribute" strategy. See the comments inside
// sync() for the algorithm.
@@ -132,7 +132,7 @@ export function useTableScroll({
// Step 1: clear any previously-forced cell widths and switch the
// 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 = ''; });
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
headTable.style.width = 'max-content';
@@ -141,7 +141,7 @@ export function useTableScroll({
bodyTable.style.tableLayout = 'auto';
// 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);
let naturalTotal = 0;
for (let i = 0; i < n; i++) {
@@ -157,7 +157,7 @@ export function useTableScroll({
const finalW = new Array(n);
let totalWidth;
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).
const scale = containerW / naturalTotal;
let running = 0;
@@ -170,7 +170,7 @@ export function useTableScroll({
finalW[n - 1] = containerW - running;
totalWidth = containerW;
} 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];
totalWidth = naturalTotal;
}
@@ -192,7 +192,7 @@ export function useTableScroll({
bodyCells[i].style.maxWidth = px;
}
// 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.
headTable.style.width = `${totalWidth}px`;
bodyTable.style.width = `${totalWidth}px`;
@@ -202,7 +202,7 @@ export function useTableScroll({
// Run once after layout
sync();
// 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
// would otherwise create a feedback loop.
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">.
// It does nothing except wire the root component to the DOM node.
@@ -8,7 +8,7 @@
// 1. index.html contains <div id="root"></div>
// 2. index.html loads THIS file as <script type="module" src="...">
// 3. The browser console (F12) shows no import errors
// ════════════════════════════════════════════════════════════════════════
// ------------------------------------------------------------------------
import { h, render } from '../vendor/preact.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
// here should need editing unless the underlying science changes.
@@ -10,63 +10,63 @@
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
// vaporPressureHpa(Ta, RH) Magnus formula hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (°)
// vaporPressureHpa(Ta, RH) Magnus formula - hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (-)
// 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';
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// 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
// work out how much heat your skin actually absorbs from the sun.
// SIGMA StefanBoltzmann constant (radiates heat)
// EPSILON_P emissivity of human skin (~0.97)
// A_K short-wave absorption coefficient for clothing
// ALBEDO_GRASS how much sun grass reflects back at you (23%)
// ALBEDO_CONCRETE how much sun concrete reflects back (30%)
// SIGMA - Stefan-Boltzmann constant (radiates heat)
// EPSILON_P - emissivity of human skin (~0.97)
// A_K - short-wave absorption coefficient for clothing
// ALBEDO_GRASS - how much sun grass reflects back at you (23%)
// ALBEDO_CONCRETE - how much sun concrete reflects back (30%)
// Concrete absorbs more net solar than grass and has
// no evaporative cooling, so its surface runs hot.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export const SIGMA = 5.670374419e-8;
export const EPSILON_P = 0.97;
export const A_K = 0.7;
export const ALBEDO_GRASS = 0.23;
export const ALBEDO_CONCRETE = 0.30;
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// CONCRETE SURFACE TEMPERATURE (Urban profile)
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// Estimates the surface temperature of exposed concrete using a
// simplified energy-balance approach:
// Absorbed solar = globalRad × (1 albedo)
// No latent heat (no evaporation concrete is dry)
// Convective loss to air proportional to wind speed
// Result is clamped to a physically plausible range
// - Absorbed solar = globalRad - (1 - albedo)
// - No latent heat (no evaporation - concrete is dry)
// - Convective loss to air proportional to wind speed
// - Result is clamped to a physically plausible range
//
// This is what matters for contact heat stress in cities the UTCI
// standard uses grass, which runs ~515 °C cooler than urban concrete
// This is what matters for contact heat stress in cities - the UTCI
// standard uses grass, which runs ~5-15 -C cooler than urban concrete
// on a sunny day because grass sweats (transpires).
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function calcConcreteTemp(Ta, globalRad, windSpeed) {
if (globalRad == null || Ta == null) return null;
const absorbed = globalRad * (1 - ALBEDO_CONCRETE); // W/m²
// Convective heat transfer coefficient: ~10 W/m²K still air, rises with wind
const absorbed = globalRad * (1 - ALBEDO_CONCRETE); // W/m-
// 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)
const hc = 10 + 4.5 * Math.sqrt(Math.max(windSpeed || 0, 0));
// Surface temp: Ta + solar gain / convective loss
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));
}
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE (windows closed, no active cooling)
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// Estimates the ambient indoor air temperature of a typical UK brick
// house with windows closed and no air conditioning.
//
@@ -74,37 +74,37 @@ export function calcConcreteTemp(Ta, globalRad, windSpeed) {
//
// 1. WALL CONDUCTION
// 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
// is used. This drives a slow, steady heat transfer proportional
// to the difference between outdoor and indoor air temperature.
//
// 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,
// and heats the indoor air. Gain is averaged across orientations
// (not all windows face south). Diffuse radiation contributes
// regardless of sun angle.
//
// 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
// target temperature from outdoor air, window solar gain, retained
// warmth, and internal gains, then the room temperature lags toward
// that target. This avoids runaway accumulation while still giving
// the characteristic late-day indoor peak.
// Call calcIndoorTempPass() on the full hourly arrays after
// building rows it returns a per-hour indoor temp array.
// building rows - it returns a per-hour indoor temp array.
//
// No mechanical cooling. Minimal infiltration (windows closed).
// Internal heat gains (people, appliances) are not modelled.
//
// Colour thresholds:
// < 20 °C cool, may need heating
// 2026 °C comfortable
// 2632 °C warm; WHO heatwave advisory threshold for sleeping
// > 32 °C hot; risk for elderly and vulnerable occupants
// ═══════════════════════════════════════════════════════════════════
// < 20 -C - cool, may need heating
// 20-26 -C - comfortable
// 26-32 -C - warm; WHO heatwave advisory threshold for sleeping
// > 32 -C - hot; risk for elderly and vulnerable occupants
// -------------------------------------------------------------------
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
// Returns an array of indoor temperatures, one per hour.
@@ -124,7 +124,7 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
const Ta = TaArr[i] ?? Ti;
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 retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
@@ -141,9 +141,9 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
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
// 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
// and a ventilation heat exchange pulls the indoor temp toward
// outdoor. When outdoor is hotter than indoor, windows are kept
// shut so this strategy never makes things worse, only better.
// shut - so this strategy never makes things worse, only better.
// Ventilation rate ~2 air changes/hour for a well-opened house.
//
// Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
@@ -176,7 +176,7 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
const Ta = TaArr[i] ?? Ti;
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 retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
@@ -196,9 +196,9 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
return result;
}
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// VEHICLE INTERIOR CABIN TEMPERATURE (seated occupant, not in sunbeam)
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// Models the ambient cabin air temperature experienced by an occupant
// seated out of direct sunlight inside a sealed, parked vehicle.
// 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
// into the cabin. Albedo ~0.25 (mid-point for typical mixed-colour
// 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)
// 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
// transmission into the cabin (rather than hitting the roof or
// 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.
// 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.
// 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
// occupied living spaces, they also retain warmth from previous hours, people,
// appliances, and background heating; without that, cool sunny days are
// under-estimated badly.
//
// Colour thresholds in the table:
// < 35 °C warm but tolerable for short periods
// 3545 °C dangerous for children/pets (hyperthermia risk)
// > 45 °C potentially fatal within minutes
// ═══════════════════════════════════════════════════════════════════
// < 35 -C - warm but tolerable for short periods
// 35-45 -C - dangerous for children/pets (hyperthermia risk)
// > 45 -C - potentially fatal within minutes
// -------------------------------------------------------------------
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false) {
if (globalRad == null || Ta == null) return null;
// Look up vehicle preset; fall back to a standard car if key unknown.
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
// ── 1. Panel conduction ──────────────────────────────────────────
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m² absorbed by bodywork
// -- 1. Panel conduction ------------------------------------------
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m- absorbed by bodywork
// 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 panelSurfaceTemp = Ta + panelAbsorbed / hOut;
// Conductive gain into cabin. Cars are thin metal + trim; motorhomes and
// caravans use insulated sandwich panels, so their bodyU is much lower.
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.
const tau = 0.70 * preset.glazingArea;
let glazingGain = 0;
if (solElev != null && solElev > 10 && solElev < 60) {
// Scale factor: peaks around 3040° elevation (sun cuts squarely
// through side glass), tapers off toward 10° (shallow/reflected)
// and 60° (sun increasingly hitting roof not side glass).
// Use a simple tent function peaking at 35°.
// Scale factor: peaks around 30-40- elevation (sun cuts squarely
// through side glass), tapers off toward 10- (shallow/reflected)
// and 60- (sun increasingly hitting roof not side glass).
// Use a simple tent function peaking at 35-.
const peak = 35;
const halfWidth = 25; // degrees either side
const factor = Math.max(0, 1 - Math.abs(solElev - peak) / halfWidth);
// 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 {
// Outside the side-window zone: diffuse only (scattered sky light)
glazingGain = tau * (globalRad * 0.15); // ~15% diffuse fraction
}
// ── Combine into cabin air temperature ───────────────────────────
// Total heat input per m² of cabin surface
// -- Combine into cabin air temperature ---------------------------
// Total heat input per m- of cabin surface
const totalGain = conductionGain + glazingGain;
// Cabin heat rejection: an effective blend of leakage, internal air volume,
// 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
// closer to ambient. Cabin temp still rises a little due to panel/roof solar gain.
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;
// 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));
}
// ── 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:
//
// FUR SURFACE TEMPERATURE
// 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:
// Black fur: albedo ~0.05 (almost all radiation absorbed)
// Brown fur: albedo ~0.15
@@ -306,44 +306,44 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
// White fur: albedo ~0.40
// Add a calcFurSurfaceTemp(Ta, globalRad, windSpeed, furAlbedo) function
// 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 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:
// < 40 °C safe
// 4052 °C discomfort / possible burn (hold-your-hand-for-7-seconds test)
// > 52 °C burns within 60 seconds
// < 40 -C - safe
// 40-52 -C - discomfort / possible burn (hold-your-hand-for-7-seconds test)
// > 52 -C - burns within 60 seconds
// Display as a simple traffic-light column in the Pets profile.
//
// BREED-SPECIFIC HEAT STRESS
// 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
// to the UTCI thresholds, similar to the planned activity modifier.
//
// SUGGESTED COLUMNS FOR PETS PROFILE
// 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
// cabin temperature for a vehicle travelling at speed, not just parked.
//
// Key physics differences from the static model:
// Forced convection over the shell scales with vehicle speed (v²), so
// hOut rises significantly shell cools much faster than when parked.
// Above ~30 mph the vehicle's own forward motion dominates airflow, so
// - Forced convection over the shell scales with vehicle speed (v-), so
// hOut rises significantly - shell cools much faster than when parked.
// - Above ~30 mph the vehicle's own forward motion dominates airflow, so
// ambient wind direction becomes largely irrelevant (simplifies the model).
// Speed classes to model: urban (~20 mph), dual carriageway (~50 mph),
// motorway (~70 mph) each with a derived hOut multiplier.
// Windows-open behaviour changes completely at speed: at 70 mph open
// - Speed classes to model: urban (~20 mph), dual carriageway (~50 mph),
// motorway (~70 mph) - each with a derived hOut multiplier.
// - Windows-open behaviour changes completely at speed: at 70 mph open
// windows create high-velocity through-flow, dramatically cutting cabin
// 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.
// AC-off vs AC-on would be the primary user toggle alongside speed class.
// - 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.
//
// Suggested signature:
// 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)")
// 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
// 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:
// Slow (10 mph) / Moderate (15 mph) / Fast (25 mph)
//
// 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)
// No cabin heating effect the cyclist gets wind chill, not solar entrapment
// High metabolic heat generation raises core temp (links to the activity
// modifier planned in compute.js cycling at speed is a combined effect)
// UVA/UVB exposure is unchanged still fully exposed to the sun
// - No cabin heating effect - the cyclist gets wind chill, not solar entrapment
// - High metabolic heat generation raises core temp (links to the activity
// modifier planned in compute.js - cycling at speed is a combined effect)
// - UVA/UVB exposure is unchanged - still fully exposed to the sun
//
// 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.
// ───────────────────────────────────────────────────────────────────
// Converts air temperature (°C) and relative humidity (%) to vapour
// -------------------------------------------------------------------
// VAPOUR PRESSURE - Magnus formula.
// -------------------------------------------------------------------
// Converts air temperature (-C) and relative humidity (%) to vapour
// pressure in hPa. Used as the humidity input to utciApprox().
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function vaporPressureHpa(Ta, RH) {
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return es * (RH / 100);
}
// ═══════════════════════════════════════════════════════════════════
// SOLAR ELEVATION NOAA simplified algorithm (degrees above horizon).
// ───────────────────────────────────────────────────────────────────
// Accurate to within ~0.01° for most practical purposes. Returns a
// -------------------------------------------------------------------
// SOLAR ELEVATION - NOAA simplified algorithm (degrees above horizon).
// -------------------------------------------------------------------
// Accurate to within ~0.01- for most practical purposes. Returns a
// 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) {
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
const diff = dateUTC - start;
@@ -422,21 +422,21 @@ export function solarElevationDeg(lat, lon, dateUTC) {
return (Math.PI / 2 - zenith) * (180 / Math.PI);
}
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// MEAN RADIANT TEMPERATURE (Tmrt)
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// Tmrt is the uniform temperature of an imaginary enclosure that would
// cause the same net radiation exchange as the actual environment.
// It accounts for:
// Direct solar beam (DNI), scaled by the projected-area factor fp
// Diffuse sky radiation (scattered and cloud-reflected)
// Ground-reflected shortwave (albedo × global radiation)
// Longwave thermal emission from surrounding surfaces ( blackbody at Ta)
// - Direct solar beam (DNI), scaled by the projected-area factor fp
// - Diffuse sky radiation (scattered and cloud-reflected)
// - Ground-reflected shortwave (albedo - global radiation)
// - Longwave thermal emission from surrounding surfaces (- blackbody at Ta)
//
// 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.
// Output feeds directly into utciApprox() as the Tmrt argument.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
const TaK = Ta + 273.15;
let fp = 0;
@@ -455,14 +455,14 @@ export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
return TmrtK - 273.15;
}
// ═══════════════════════════════════════════════════════════════════
// UTCI POLYNOMIAL DO NOT EDIT (or be VERY careful if you do)
// ───────────────────────────────────────────────────────────────────
// This is the official 210-term Bröde et al. (2012) approximation.
// -------------------------------------------------------------------
// UTCI POLYNOMIAL - DO NOT EDIT (or be VERY careful if you do)
// -------------------------------------------------------------------
// This is the official 210-term Br-de et al. (2012) approximation.
// It takes air temp, mean radiant temp, wind, and humidity and returns
// 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) {
const va = Math.max(0.5, Math.min(17, va10));
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
// to call server-side or in tests.
//
// Exports (in order of appearance):
// utciCategory(u) UTCI stress band {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (°C penalty)
// windCompass8(deg) bearing {label, snapped}
// uvSplit(uv, elevDeg) total UV index {uvA, uvB} estimate
// SKIN_TYPES Fitzpatrick IVI lookup table
// utciCategory(u) UTCI stress band - {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (-C penalty)
// windCompass8(deg) bearing - {label, snapped}
// uvSplit(uv, elevDeg) total UV index - {uvA, uvB} estimate
// SKIN_TYPES Fitzpatrick I-VI lookup table
// sunburnMinutes(uv, skinType) minutes to MED (sunburn threshold)
// burnLabel(mins) formats burn time as "12m" / "1.5h"
// VEHICLE_TYPES vehicle presets for cabin 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
// moonPhaseFraction(date) 0..1 synodic phase fraction
// moonGlyph(p) phase fraction moon emoji
// skyGradientForElev(e, isRising) solar elevation {top,bot} hex pair
// moonGlyph(p) phase fraction - moon emoji
// skyGradientForElev(e, isRising) solar elevation - {top,bot} hex pair
// 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
// 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) {
if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', 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' };
}
// ═══════════════════════════════════════════════════════════════════
// 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
// extra felt-temperature for rain (wet clothing = evaporative chill)
// 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
// freak 50mm/h reading can't make UTCI nonsensical.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function precipPenalty(precipMm, snowCmH, windMs) {
let penalty = 0;
if (precipMm > 0) {
@@ -65,13 +65,13 @@ export function precipPenalty(precipMm, snowCmH, windMs) {
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
// the arrow's tail points to this bearing (i.e. shows where the wind
// comes from), matching how real weather vanes behave.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function windCompass8(deg) {
if (deg == null || isNaN(deg)) return { label: '—', snapped: 0 };
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 };
}
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// UV-A / UV-B SPLIT (estimate, not measurement).
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// 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
// to solar elevation because of atmospheric path length.
//
// Cheap model:
// 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
// same units the user already understands.
// Label these as estimates in the UI.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function uvSplit(uv, elevDeg) {
if (!uv || uv <= 0 || elevDeg <= 0) return { uvA: 0, uvB: 0 };
const sinE = Math.sin(elevDeg * Math.PI / 180);
@@ -101,13 +101,13 @@ export function uvSplit(uv, elevDeg) {
return { uvA: uv * uvaFr, uvB: uv * uvbFr };
}
// ═══════════════════════════════════════════════════════════════════
// SUNBURN TIME minutes to MED for the chosen Fitzpatrick skin type.
// ───────────────────────────────────────────────────────────────────
// Standard erythemal model: time_min base_minutes[type] / UV_index.
// -------------------------------------------------------------------
// SUNBURN TIME - minutes to MED for the chosen Fitzpatrick skin type.
// -------------------------------------------------------------------
// Standard erythemal model: time_min - base_minutes[type] / UV_index.
// Numbers are the well-known "unprotected, midday, no sunscreen"
// reference times at UV = 1. Returns Infinity when UV is 0 (night).
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export const SKIN_TYPES = {
I: { name: 'I · Very fair', base: 67 },
II: { name: 'II · Fair', base: 100 },
@@ -128,21 +128,21 @@ export function burnLabel(mins) {
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:
// albedo how much solar the bodywork reflects (0 = black, 1 = mirror)
// glazingArea relative sun-exposed glass area (1.0 = typical car)
// bodyU effective body/panel conductance into the cabin. Cars are
// albedo - how much solar the bodywork reflects (0 = black, 1 = mirror)
// glazingArea - relative sun-exposed glass area (1.0 = typical car)
// bodyU - effective body/panel conductance into the cabin. Cars are
// thin metal/glass boxes; motorhomes/caravans have insulated
// sandwich panels, commonly around 2535 mm thick.
// hCabinLoss effective heat rejection/infiltration from the cabin air.
// thermalMass lower values mean the interior warms more slowly in the hour.
// retainedWarmth occupied insulated living spaces hold heat from previous
// sandwich panels, commonly around 25-35 mm thick.
// hCabinLoss - effective heat rejection/infiltration from the cabin air.
// thermalMass - lower values mean the interior warms more slowly in the hour.
// retainedWarmth - occupied insulated living spaces hold heat from previous
// 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 = {
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 },
@@ -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 },
};
// ═══════════════════════════════════════════════════════════════════
// BUILDING TYPES presets for the indoor temperature model.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// BUILDING TYPES - presets for the indoor temperature model.
// -------------------------------------------------------------------
// 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.
// lagHours h Thermal mass time constant. Heavier construction = longer
// lag before indoor temp follows outdoor changes.
// glazingRatio Fraction of floor area that is window. More glass = more
// glazingRatio - Fraction of floor area that is window. More glass = more
// solar gain in summer, more heat loss in winter.
// gValue Solar heat gain coefficient of glazing. 0.63 = standard
// gValue - Solar heat gain coefficient of glazing. 0.63 = standard
// double glazing; 0.3 = modern low-e triple.
// orientFactor Fraction of windows facing the sun at any given time.
// orientFactor - Fraction of windows facing the sun at any given time.
// 0.5 = random orientation; 0.8 = south-facing conservatory.
// curtainBlock Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains 0.80; blinds 0.50.
// ventAlpha Blending weight per hour when smart ventilation is open.
// curtainBlock - Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains - 0.80; blinds - 0.50.
// ventAlpha - Blending weight per hour when smart ventilation is open.
// Higher = more air changes per hour.
// solarScale Converts effective window solar gain into an indoor
// solarScale - Converts effective window solar gain into an indoor
// temperature lift. Lower values mean more thermal mass.
// baseTemp Occupied/retained warmth baseline for normal homes.
// internalGain Small heat gain from people, appliances, and background use.
// retainedScale How strongly the building holds above-outdoor warmth in
// baseTemp - Occupied/retained warmth baseline for normal homes.
// internalGain - Small heat gain from people, appliances, and background use.
// retainedScale - How strongly the building holds above-outdoor warmth in
// cool conditions. Higher = better retained warmth.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export const BUILDING_TYPES = {
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25, solarScale: 0.10, baseTemp: 16.5, internalGain: 0.8, retainedScale: 0.35 },
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.55 },
@@ -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 },
};
// ═══════════════════════════════════════════════════════════════════
// 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'
// Uses total cover for headline level, but biases towards 'wispy'
// when only high cloud is present (cirrus barely blocks the sun).
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function cloudCategory(total, low, mid, high) {
const t = total ?? 0;
const l = low ?? 0;
const m = mid ?? 0;
const h = high ?? 0;
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 (t < 40) return 'wispy';
if (t < 75) return 'scattered';
return 'overcast';
}
// ═══════════════════════════════════════════════════════════════════
// CONFIDENCE BANDS smooth high-noon sunset gradient on day tabs.
// ───────────────────────────────────────────────────────────────────
// `i` is the day index (0 = today, 13 = day 14).
// -------------------------------------------------------------------
// CONFIDENCE BANDS - smooth high-noon - sunset gradient on day tabs.
// -------------------------------------------------------------------
// 'i' is the day index (0 = today, 13 = day 14).
//
// Each day gets its own shade, interpolated between two endpoint
// 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
// description ("you're in the trustworthy zone" vs "this is an
// outlook"). The tab background itself flows smoothly day to day.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function confidenceBand(i) {
// Position along the gradient: 0 on day 0, 1 on day 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 bgEnd = [232, 152, 104]; // #e89868 terracotta (sunset)
const edgeStart = [245, 231, 161]; // #f5e7a1 soft golden
@@ -246,24 +246,24 @@ export function confidenceBand(i) {
bg: `rgb(${br}, ${bg}, ${bb})`,
edge: `rgb(${er}, ${eg}, ${eb})`,
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).
label: i < 3 ? 'Pin-sharp' // days 13 highest skill
: i < 7 ? 'Sharp' // days 47 solid
: i < 10 ? 'Soft focus' // days 810 trends only
: 'Blurry', // days 1114 outlook only
label: i < 3 ? 'Pin-sharp' // days 1-3 highest skill
: i < 7 ? 'Sharp' // days 4-7 solid
: i < 10 ? 'Soft focus' // days 8-10 trends 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:
// 0.00 = new moon 0.50 = full moon
// 0.25 = first quarter 0.75 = last quarter
// The maths is a simple synodic-period calculation referenced from
// a known new moon (6 Jan 2000). Accurate to within a few hours.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function moonPhaseFraction(date) {
const JD = date.getTime() / 86400000 + 2440587.5;
const syn = 29.530588;
@@ -277,23 +277,23 @@ export function moonGlyph(p) {
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).
//
// Sunrise palette reds, oranges, yellows at the horizon.
// Sunset palette pinks, purples, mauves at the horizon.
// Sunrise palette - reds, oranges, yellows at the horizon.
// Sunset palette - pinks, purples, mauves at the horizon.
// 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.
const SKY_KEYS_RISING = [
[-18, '#0e0c28', '#1a1538'], // deep night
[-14, '#1e1a50', '#3a2f60'], // nautical twilight
[ -8, '#2a3a6a', '#a04828'], // pre-dawn: indigo burnt sienna
[ -3, '#4a7aaa', '#e8622a'], // horizon band: deep blue vivid red-orange
[ 3, '#6aaddb', '#ffb347'], // golden hour: blue warm amber
[ -8, '#2a3a6a', '#a04828'], // pre-dawn: indigo - burnt sienna
[ -3, '#4a7aaa', '#e8622a'], // horizon band: deep blue - vivid red-orange
[ 3, '#6aaddb', '#ffb347'], // golden hour: blue - warm amber
[ 10, '#7cc5ec', '#c8e3ee'], // low sun: pale blue sky
[ 30, '#5bb8e8', '#9fd3ef'], // mid-day blue
[ 60, '#2e8fd4', '#7cc8ef'], // high noon: rich deep blue
@@ -302,9 +302,9 @@ const SKY_KEYS_RISING = [
const SKY_KEYS_SETTING = [
[-18, '#0e0c28', '#1a1538'],
[-14, '#1e1a50', '#3a2f60'],
[ -8, '#5a3572', '#c07080'], // dusk: purple dusty rose/mauve
[ -3, '#7a5090', '#e8826a'], // horizon band: violet coral/pink
[ 3, '#7b8fc4', '#ffb877'], // golden hour: blue-violet golden
[ -8, '#5a3572', '#c07080'], // dusk: purple - dusty rose/mauve
[ -3, '#7a5090', '#e8826a'], // horizon band: violet - coral/pink
[ 3, '#7b8fc4', '#ffb877'], // golden hour: blue-violet - golden
[ 10, '#7cc5ec', '#c8e3ee'],
[ 30, '#5bb8e8', '#9fd3ef'],
[ 60, '#2e8fd4', '#7cc8ef'],
@@ -344,20 +344,20 @@ export function skyGradientForElev(e, isRising) {
return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
}
// ─── skyFillForElev ────────────────────────────────────────────────────────
// --- skyFillForElev --------------------------------------------------------
// Convenience single-colour fill for contexts that don't need a gradient
// (e.g. solid background chips). Returns the horizon (bottom) colour.
export function skyFillForElev(e, isRising = false) {
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
// green at noon, amber at golden hour, purple-dark at twilight/night.
// Returns {top, bot} for a subtle two-stop ground gradient.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function grassFillForElev(e) {
if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday