1.5.7
Profile memory Comment cleanup
This commit is contained in:
+38
-115
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: '🔴',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 — 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%)
|
||||
// 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 ~5–15 °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.28–0.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 ~15–18% 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
|
||||
// 20–26 °C — comfortable
|
||||
// 26–32 °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 25–35 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
|
||||
// 35–45 °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 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°.
|
||||
// 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
|
||||
// 40–52 °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 −6°, 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
@@ -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 I–VI 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 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
|
||||
// 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 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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user