Profile memory
Comment cleanup
This commit is contained in:
fraxle
2026-05-18 23:02:41 +01:00
parent dded468bec
commit 85c415d1f5
16 changed files with 504 additions and 581 deletions
+100 -100
View File
@@ -1,35 +1,35 @@
// ════════════════════════════════════════════════════════════════════════
// utils.js Pure helper functions and lookup tables.
// ------------------------------------------------------------------------
// utils.js - Pure helper functions and lookup tables.
//
// No side-effects, no DOM access, no API calls. All functions are safe
// to call server-side or in tests.
//
// Exports (in order of appearance):
// utciCategory(u) UTCI stress band {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (°C penalty)
// windCompass8(deg) bearing {label, snapped}
// uvSplit(uv, elevDeg) total UV index {uvA, uvB} estimate
// SKIN_TYPES Fitzpatrick IVI lookup table
// utciCategory(u) UTCI stress band - {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (-C penalty)
// windCompass8(deg) bearing - {label, snapped}
// uvSplit(uv, elevDeg) total UV index - {uvA, uvB} estimate
// SKIN_TYPES Fitzpatrick I-VI lookup table
// sunburnMinutes(uv, skinType) minutes to MED (sunburn threshold)
// burnLabel(mins) formats burn time as "12m" / "1.5h"
// VEHICLE_TYPES vehicle presets for cabin heat model
// BUILDING_TYPES building presets for indoor heat model
// cloudCategory(total,low,mid,high) 'clear'|'wispy'|'scattered'|'overcast'
// cloudCategory(total,low,mid,high) - 'clear'|'wispy'|'scattered'|'overcast'
// confidenceBand(i) day-tab gradient colour + label
// moonPhaseFraction(date) 0..1 synodic phase fraction
// moonGlyph(p) phase fraction moon emoji
// skyGradientForElev(e, isRising) solar elevation {top,bot} hex pair
// moonGlyph(p) phase fraction - moon emoji
// skyGradientForElev(e, isRising) solar elevation - {top,bot} hex pair
// skyFillForElev(e, isRising) convenience single-colour sky fill
// grassFillForElev(e) solar elevation {top,bot} ground hex
// ════════════════════════════════════════════════════════════════════════
// grassFillForElev(e) solar elevation - {top,bot} ground hex
// ------------------------------------------------------------------------
// ═══════════════════════════════════════════════════════════════════
// UTCI THERMAL STRESS BANDS the coloured pills in the table.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// UTCI THERMAL STRESS BANDS - the coloured pills in the table.
// -------------------------------------------------------------------
// To recolour any band, change its bg/fg hex code. To shift the
// boundary between bands (e.g. make "Comfortable" wider), change the
// `if (u < …)` thresholds. Order matters they're checked top-down.
// ═══════════════════════════════════════════════════════════════════
// 'if (u < -)' thresholds. Order matters - they're checked top-down.
// -------------------------------------------------------------------
export function utciCategory(u) {
if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', fg: '#fff' };
if (u < -27) return { label: 'Very strong cold', bg: '#23408f', fg: '#fff' };
@@ -44,16 +44,16 @@ export function utciCategory(u) {
return { label: 'Extreme heat', bg: '#7a1a1a', fg: '#fff' };
}
// ═══════════════════════════════════════════════════════════════════
// SOAK-FACTOR SunScope's original rain/snow penalty.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// SOAK-FACTOR - SunScope's original rain/snow penalty.
// -------------------------------------------------------------------
// This is what makes "UTCI+P" different from plain UTCI. It subtracts
// extra felt-temperature for rain (wet clothing = evaporative chill)
// and snow (wet snow is brutal). Wind amplifies the rain penalty.
// Calibrated by feel adjust the multipliers if you find it too
// Calibrated by feel - adjust the multipliers if you find it too
// strong/weak. The big number "7" caps the max rain penalty so a
// freak 50mm/h reading can't make UTCI nonsensical.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function precipPenalty(precipMm, snowCmH, windMs) {
let penalty = 0;
if (precipMm > 0) {
@@ -65,13 +65,13 @@ export function precipPenalty(precipMm, snowCmH, windMs) {
return -Math.round(penalty * 10) / 10;
}
// ═══════════════════════════════════════════════════════════════════
// WIND COMPASS meteorological bearing (deg FROM) 8-point label.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// WIND COMPASS - meteorological bearing (deg FROM) - 8-point label.
// -------------------------------------------------------------------
// 0/360 = wind FROM north. The pointer in WindVane should rotate so
// the arrow's tail points to this bearing (i.e. shows where the wind
// comes from), matching how real weather vanes behave.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function windCompass8(deg) {
if (deg == null || isNaN(deg)) return { label: '—', snapped: 0 };
const dirs = ['N','NE','E','SE','S','SW','W','NW'];
@@ -79,20 +79,20 @@ export function windCompass8(deg) {
return { label: dirs[idx], snapped: idx * 45 };
}
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
// UV-A / UV-B SPLIT (estimate, not measurement).
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// Open-Meteo gives a total erythemal UV index. UV-A reaches the
// surface much more reliably than UV-B; UV-B is far more sensitive
// to solar elevation because of atmospheric path length.
//
// Cheap model:
// At noon (sun overhead) the UV-B share of total UV index is ~15%,
// UV-A about 85%. Below ~10° solar elevation, UV-B falls off fast.
// UV-A about 85%. Below ~10- solar elevation, UV-B falls off fast.
// We return two pseudo-"index" numbers so the columns are in the
// same units the user already understands.
// Label these as estimates in the UI.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function uvSplit(uv, elevDeg) {
if (!uv || uv <= 0 || elevDeg <= 0) return { uvA: 0, uvB: 0 };
const sinE = Math.sin(elevDeg * Math.PI / 180);
@@ -101,13 +101,13 @@ export function uvSplit(uv, elevDeg) {
return { uvA: uv * uvaFr, uvB: uv * uvbFr };
}
// ═══════════════════════════════════════════════════════════════════
// SUNBURN TIME minutes to MED for the chosen Fitzpatrick skin type.
// ───────────────────────────────────────────────────────────────────
// Standard erythemal model: time_min base_minutes[type] / UV_index.
// -------------------------------------------------------------------
// SUNBURN TIME - minutes to MED for the chosen Fitzpatrick skin type.
// -------------------------------------------------------------------
// Standard erythemal model: time_min - base_minutes[type] / UV_index.
// Numbers are the well-known "unprotected, midday, no sunscreen"
// reference times at UV = 1. Returns Infinity when UV is 0 (night).
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export const SKIN_TYPES = {
I: { name: 'I · Very fair', base: 67 },
II: { name: 'II · Fair', base: 100 },
@@ -128,21 +128,21 @@ export function burnLabel(mins) {
return `${Math.round(mins)}m`;
}
// ═══════════════════════════════════════════════════════════════════
// VEHICLE TYPES presets for the cabin heat model.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// VEHICLE TYPES - presets for the cabin heat model.
// -------------------------------------------------------------------
// Each entry tweaks the physical levers in calcVehicleInteriorTemp:
// albedo how much solar the bodywork reflects (0 = black, 1 = mirror)
// glazingArea relative sun-exposed glass area (1.0 = typical car)
// bodyU effective body/panel conductance into the cabin. Cars are
// albedo - how much solar the bodywork reflects (0 = black, 1 = mirror)
// glazingArea - relative sun-exposed glass area (1.0 = typical car)
// bodyU - effective body/panel conductance into the cabin. Cars are
// thin metal/glass boxes; motorhomes/caravans have insulated
// sandwich panels, commonly around 2535 mm thick.
// hCabinLoss effective heat rejection/infiltration from the cabin air.
// thermalMass lower values mean the interior warms more slowly in the hour.
// retainedWarmth occupied insulated living spaces hold heat from previous
// sandwich panels, commonly around 25-35 mm thick.
// hCabinLoss - effective heat rejection/infiltration from the cabin air.
// thermalMass - lower values mean the interior warms more slowly in the hour.
// retainedWarmth - occupied insulated living spaces hold heat from previous
// hours, people, appliances, and background heating.
// internalGain small living-space warmth boost when closed up.
// ═══════════════════════════════════════════════════════════════════
// internalGain - small living-space warmth boost when closed up.
// -------------------------------------------------------------------
export const VEHICLE_TYPES = {
car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.0, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.3, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
@@ -151,32 +151,32 @@ export const VEHICLE_TYPES = {
caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 },
};
// ═══════════════════════════════════════════════════════════════════
// BUILDING TYPES presets for the indoor temperature model.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// BUILDING TYPES - presets for the indoor temperature model.
// -------------------------------------------------------------------
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass.
//
// uWall W/m²K Effective envelope U-value. Higher = faster response
// uWall W/m-K Effective envelope U-value. Higher = faster response
// to outdoor swings, less insulation.
// lagHours h Thermal mass time constant. Heavier construction = longer
// lag before indoor temp follows outdoor changes.
// glazingRatio Fraction of floor area that is window. More glass = more
// glazingRatio - Fraction of floor area that is window. More glass = more
// solar gain in summer, more heat loss in winter.
// gValue Solar heat gain coefficient of glazing. 0.63 = standard
// gValue - Solar heat gain coefficient of glazing. 0.63 = standard
// double glazing; 0.3 = modern low-e triple.
// orientFactor Fraction of windows facing the sun at any given time.
// orientFactor - Fraction of windows facing the sun at any given time.
// 0.5 = random orientation; 0.8 = south-facing conservatory.
// curtainBlock Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains 0.80; blinds 0.50.
// ventAlpha Blending weight per hour when smart ventilation is open.
// curtainBlock - Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains - 0.80; blinds - 0.50.
// ventAlpha - Blending weight per hour when smart ventilation is open.
// Higher = more air changes per hour.
// solarScale Converts effective window solar gain into an indoor
// solarScale - Converts effective window solar gain into an indoor
// temperature lift. Lower values mean more thermal mass.
// baseTemp Occupied/retained warmth baseline for normal homes.
// internalGain Small heat gain from people, appliances, and background use.
// retainedScale How strongly the building holds above-outdoor warmth in
// baseTemp - Occupied/retained warmth baseline for normal homes.
// internalGain - Small heat gain from people, appliances, and background use.
// retainedScale - How strongly the building holds above-outdoor warmth in
// cool conditions. Higher = better retained warmth.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export const BUILDING_TYPES = {
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25, solarScale: 0.10, baseTemp: 16.5, internalGain: 0.8, retainedScale: 0.35 },
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.55 },
@@ -187,30 +187,30 @@ export const BUILDING_TYPES = {
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50, solarScale: 0.045, baseTemp: 12.0, internalGain: 0.2, retainedScale: 0.05 },
};
// ═══════════════════════════════════════════════════════════════════
// CLOUD CATEGORY pick one of 4 icon styles from low/mid/high split.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// CLOUD CATEGORY - pick one of 4 icon styles from low/mid/high split.
// -------------------------------------------------------------------
// Returns: 'clear' | 'wispy' | 'scattered' | 'overcast'
// Uses total cover for headline level, but biases towards 'wispy'
// when only high cloud is present (cirrus barely blocks the sun).
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function cloudCategory(total, low, mid, high) {
const t = total ?? 0;
const l = low ?? 0;
const m = mid ?? 0;
const h = high ?? 0;
if (t < 10) return 'clear';
// Mostly high cloud with little low/mid wispy regardless of % total
// Mostly high cloud with little low/mid - wispy regardless of % total
if (h > 40 && l < 25 && m < 25) return 'wispy';
if (t < 40) return 'wispy';
if (t < 75) return 'scattered';
return 'overcast';
}
// ═══════════════════════════════════════════════════════════════════
// CONFIDENCE BANDS smooth high-noon sunset gradient on day tabs.
// ───────────────────────────────────────────────────────────────────
// `i` is the day index (0 = today, 13 = day 14).
// -------------------------------------------------------------------
// CONFIDENCE BANDS - smooth high-noon - sunset gradient on day tabs.
// -------------------------------------------------------------------
// 'i' is the day index (0 = today, 13 = day 14).
//
// Each day gets its own shade, interpolated between two endpoint
// colours. To re-skin the gradient (say, blue-to-purple instead of
@@ -224,12 +224,12 @@ export function cloudCategory(total, low, mid, high) {
// The label switches in 4 stages so users still see a friendly
// description ("you're in the trustworthy zone" vs "this is an
// outlook"). The tab background itself flows smoothly day to day.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function confidenceBand(i) {
// Position along the gradient: 0 on day 0, 1 on day 13.
const t = Math.min(1, Math.max(0, i / 13));
// ENDPOINTS change these four RGB arrays to re-skin the gradient.
// ENDPOINTS - change these four RGB arrays to re-skin the gradient.
const bgStart = [255, 247, 214]; // #fff7d6 pale yellow (high noon)
const bgEnd = [232, 152, 104]; // #e89868 terracotta (sunset)
const edgeStart = [245, 231, 161]; // #f5e7a1 soft golden
@@ -246,24 +246,24 @@ export function confidenceBand(i) {
bg: `rgb(${br}, ${bg}, ${bb})`,
edge: `rgb(${er}, ${eg}, ${eb})`,
tint: `rgba(${er}, ${eg}, ${eb}, 0.22)`,
// Qualitative confidence label (camera-focus metaphor
// Qualitative confidence label (camera-focus metaphor -
// on-brand for SunScope, and instantly readable).
label: i < 3 ? 'Pin-sharp' // days 13 highest skill
: i < 7 ? 'Sharp' // days 47 solid
: i < 10 ? 'Soft focus' // days 810 trends only
: 'Blurry', // days 1114 outlook only
label: i < 3 ? 'Pin-sharp' // days 1-3 highest skill
: i < 7 ? 'Sharp' // days 4-7 solid
: i < 10 ? 'Soft focus' // days 8-10 trends only
: 'Blurry', // days 11-14 outlook only
};
}
// ═══════════════════════════════════════════════════════════════════
// MOON PHASE works out which moon emoji to show on night hours.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// MOON PHASE - works out which moon emoji to show on night hours.
// -------------------------------------------------------------------
// Returns a fraction 0..1:
// 0.00 = new moon 0.50 = full moon
// 0.25 = first quarter 0.75 = last quarter
// The maths is a simple synodic-period calculation referenced from
// a known new moon (6 Jan 2000). Accurate to within a few hours.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function moonPhaseFraction(date) {
const JD = date.getTime() / 86400000 + 2440587.5;
const syn = 29.530588;
@@ -277,23 +277,23 @@ export function moonGlyph(p) {
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p * 8 + 0.5) % 8];
}
// ═══════════════════════════════════════════════════════════════════
// SKY GRADIENT top/bottom colour pair for the SkyScope disk gradient.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// SKY GRADIENT - top/bottom colour pair for the SkyScope disk gradient.
// -------------------------------------------------------------------
// isRising: true when sun is climbing (local hour < 12).
//
// Sunrise palette reds, oranges, yellows at the horizon.
// Sunset palette pinks, purples, mauves at the horizon.
// Sunrise palette - reds, oranges, yellows at the horizon.
// Sunset palette - pinks, purples, mauves at the horizon.
// Both share the same deep blue zenith at high elevations.
// ═══════════════════════════════════════════════════════════════════
// Sky colour keyframes [elevation, topHex, botHex] for rising and setting sun.
// -------------------------------------------------------------------
// Sky colour keyframes - [elevation, topHex, botHex] for rising and setting sun.
// Colours are smoothly interpolated between adjacent keyframes.
const SKY_KEYS_RISING = [
[-18, '#0e0c28', '#1a1538'], // deep night
[-14, '#1e1a50', '#3a2f60'], // nautical twilight
[ -8, '#2a3a6a', '#a04828'], // pre-dawn: indigo burnt sienna
[ -3, '#4a7aaa', '#e8622a'], // horizon band: deep blue vivid red-orange
[ 3, '#6aaddb', '#ffb347'], // golden hour: blue warm amber
[ -8, '#2a3a6a', '#a04828'], // pre-dawn: indigo - burnt sienna
[ -3, '#4a7aaa', '#e8622a'], // horizon band: deep blue - vivid red-orange
[ 3, '#6aaddb', '#ffb347'], // golden hour: blue - warm amber
[ 10, '#7cc5ec', '#c8e3ee'], // low sun: pale blue sky
[ 30, '#5bb8e8', '#9fd3ef'], // mid-day blue
[ 60, '#2e8fd4', '#7cc8ef'], // high noon: rich deep blue
@@ -302,9 +302,9 @@ const SKY_KEYS_RISING = [
const SKY_KEYS_SETTING = [
[-18, '#0e0c28', '#1a1538'],
[-14, '#1e1a50', '#3a2f60'],
[ -8, '#5a3572', '#c07080'], // dusk: purple dusty rose/mauve
[ -3, '#7a5090', '#e8826a'], // horizon band: violet coral/pink
[ 3, '#7b8fc4', '#ffb877'], // golden hour: blue-violet golden
[ -8, '#5a3572', '#c07080'], // dusk: purple - dusty rose/mauve
[ -3, '#7a5090', '#e8826a'], // horizon band: violet - coral/pink
[ 3, '#7b8fc4', '#ffb877'], // golden hour: blue-violet - golden
[ 10, '#7cc5ec', '#c8e3ee'],
[ 30, '#5bb8e8', '#9fd3ef'],
[ 60, '#2e8fd4', '#7cc8ef'],
@@ -344,20 +344,20 @@ export function skyGradientForElev(e, isRising) {
return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
}
// ─── skyFillForElev ────────────────────────────────────────────────────────
// --- skyFillForElev --------------------------------------------------------
// Convenience single-colour fill for contexts that don't need a gradient
// (e.g. solid background chips). Returns the horizon (bottom) colour.
export function skyFillForElev(e, isRising = false) {
return skyGradientForElev(e, isRising).bot;
}
// ═══════════════════════════════════════════════════════════════════
// GRASS FILL ground-strip colour keyed to solar elevation.
// ───────────────────────────────────────────────────────────────────
// -------------------------------------------------------------------
// GRASS FILL - ground-strip colour keyed to solar elevation.
// -------------------------------------------------------------------
// Mirrors the sky palette so the SkyScope disk reads naturally: vivid
// green at noon, amber at golden hour, purple-dark at twilight/night.
// Returns {top, bot} for a subtle two-stop ground gradient.
// ═══════════════════════════════════════════════════════════════════
// -------------------------------------------------------------------
export function grassFillForElev(e) {
if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday