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