Files
sunscope/assets/js/utils.js
T
fraxle 71860b9dd9 5.4.0
Restructure
New Journal entries
Colour tabs update with wind
2026-08-31 15:12:45 +01:00

653 lines
36 KiB
JavaScript

// ------------------------------------------------------------------------
// utils.js - Pure helper functions and lookup tables.
//
// No side-effects, no DOM access, no API calls. All functions are safe
// to call server-side or in tests.
//
// Exports (in order of appearance):
// utciCategory(u) UTCI stress band - {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (-C penalty)
// windCompass8(deg) bearing - {label, snapped}
// uvSplit(uv, elevDeg) total UV index - {uvA, uvB} estimate
// SKIN_TYPES Fitzpatrick I-VI lookup table
// 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
// FUR_COLORS fur presets for the fur surface temp model
// PET_BANDS / petCategory(t) pet skin/surface stress band - {label,bg,fg}
// 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
// skyFillForElev(e, isRising) convenience single-colour sky fill
// grassFillForElev(e) solar elevation - {top,bot} ground hex
// ------------------------------------------------------------------------
// -------------------------------------------------------------------
// UTCI THERMAL STRESS BANDS - single source of truth.
// -------------------------------------------------------------------
// Edit this array to change labels, colours or thresholds.
// Each entry: { max, label, bg, fg }
// max - upper boundary (exclusive). Omit on the last entry.
// bg - background hex for table cells and legend.
// fg - foreground hex for text on that background.
// utciCategory() reads from this array - do not duplicate elsewhere.
// -------------------------------------------------------------------
export const UTCI_BANDS = [
{ max: -20, label: 'Extreme cold', bg: '#9b7fc2', fg: '#ffffff' },
{ max: -10, label: 'Arctic', bg: '#7fa8d8', fg: '#ffffff' },
{ max: 0, label: 'Freezing', bg: '#7ec0e8', fg: '#1a1200' },
{ max: 5, label: 'Very cold', bg: '#a8d4ee', fg: '#1a1200' },
{ max: 10, label: 'Cold', bg: '#b8e0e8', fg: '#1a1200' },
{ max: 15, label: 'Chilly', bg: '#b8e8d8', fg: '#1a1200' },
{ max: 19, label: 'Cool', bg: '#c5e8c0', fg: '#1a1200' },
{ max: 24, label: 'Comfortable', bg: '#90d090', fg: '#157a15', fontWeight: 700 },
{ max: 27, label: 'Warm', bg: '#f8e554', fg: '#1a1200' },
{ max: 32, label: 'Caution', bg: '#f5aa54', fg: '#1a1200' },
{ max: 41, label: 'Extreme', bg: '#f5884a', fg: '#000000' },
{ label: 'Danger', bg: '#880000', fg: '#ffffff', fontWeight: 700, darkenAmt: 0.18, textShadow: '-1px -1px 0 #3a0000, 1px -1px 0 #3a0000, -1px 1px 0 #3a0000, 1px 1px 0 #3a0000', solid: true },
];
export function utciCategory(u) {
for (const band of UTCI_BANDS) {
if (band.max === undefined || u < band.max) return band;
}
}
// -------------------------------------------------------------------
// BAND COLOUR RAMP - continuous version of the banded palette.
// -------------------------------------------------------------------
// utciCategory()/petCategory() hand back ONE flat colour per band, which
// is right for table cells (a cell states which band you are in) but wrong
// for anything that should read as a temperature gradient: every day from
// 27 -C to 31.9 -C is the same "Caution" swatch, so the only visible change
// is the hard jump at the next threshold.
//
// bandRampRgb() anchors each band's colour at the BOTTOM of that band's
// range and interpolates toward the next band's colour across the band. So
// a temperature sitting exactly on a threshold still gets that band's own
// published colour (32 -C is still the Extreme orange), and every degree
// above it slides proportionally toward the next tier - 30 -C lands 60% of
// the way from Caution to Extreme instead of being identical to 27 -C.
//
// The solid Danger band is left out of the ramp: it's a flat stop-everything
// alarm colour, not part of the gradient, so the ramp clamps at the last
// graded band and callers keep handling Danger separately.
// -------------------------------------------------------------------
const rampAnchorCache = new WeakMap();
function rampAnchors(bands) {
let anchors = rampAnchorCache.get(bands);
if (anchors) return anchors;
anchors = [];
let lower = null;
for (const band of bands) {
if (band.solid) break;
// The first band is open-ended downward; give it a nominal 10 -C span so
// it still has an anchor below its own threshold.
anchors.push({ at: lower === null ? band.max - 10 : lower, rgb: hexToRgb(band.bg) });
if (band.max === undefined) break;
lower = band.max;
}
rampAnchorCache.set(bands, anchors);
return anchors;
}
export function bandRampRgb(t, bands) {
const anchors = rampAnchors(bands);
if (t === null || !isFinite(t)) return anchors[0].rgb.slice();
if (t <= anchors[0].at) return anchors[0].rgb.slice();
const last = anchors[anchors.length - 1];
if (t >= last.at) return last.rgb.slice();
for (let i = 1; i < anchors.length; i++) {
const a = anchors[i - 1], b = anchors[i];
if (t <= b.at) return mixRgb(a.rgb, b.rgb, (t - a.at) / (b.at - a.at));
}
return last.rgb.slice();
}
// -------------------------------------------------------------------
// WEATHER TINT PALETTE - single source of truth for "what colour is
// this weather", shared by the day tabs (components/DayTabs.js) and the
// warning event banner (events/weather-checks.js + app.js).
// -------------------------------------------------------------------
// The day tabs start from a temperature colour (utciCategory().bg /
// petCategory().bg, ramped by bandRampRgb) and then shade it toward the
// sky by influence - rain/snow first, then cloud on days that will stay
// dry. These helpers are the shade ramps for those non-temperature
// dimensions; see the WEIGHTED model block in components/DayTabs.js.
// -------------------------------------------------------------------
export const hexToRgb = (hx) => {
const n = parseInt(hx.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
export const mixRgb = (a, b, t) =>
a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t))));
// Rain: light -> deep blue with amount (mm over the period).
export const rainTint = (mm) => mixRgb([150, 176, 212], [44, 84, 150], mm / 8);
// Cloud: light -> dark grey with cover (%).
export const cloudTint = (pct) => mixRgb([205, 206, 208], [110, 115, 122], (pct - 30) / 70);
// Snow: pale -> icy blue with depth (cm).
export const snowTint = (cm) => mixRgb([226, 234, 244], [176, 202, 232], cm / 1.5);
// -------------------------------------------------------------------
// PET_BANDS - same shape as UTCI_BANDS, recalibrated for pet skin/fur
// instead of bare human skin.
// -------------------------------------------------------------------
// Covers the pet-specific columns (Fur Colour, Pet Shade, Pet Home, Paw)
// in the table and simple view. Fur, a thicker dermis, and natural skin
// oils give real extra insulation at both ends of the scale, so every
// threshold is shifted from UTCI_BANDS, not just the warm half:
// - Warm half shifted up - calibrated against the pavement-burn guidance
// this app already used ("7-second hand test": safe below 40 -C,
// caution to 52 -C, danger above) and the furSurfaceT glance-card
// alert (>=45 -C, which now falls inside "Extreme" rather than
// jumping straight to "Danger").
// - Cold half shifted down ~8 -C - a healthy cat or dog's coat copes
// with a frosty night (e.g. -2 -C) that would be "Freezing" on the
// bare-skin human scale; here that same reading lands in "Cold".
//
// Colours are re-interpolated (not copy-pasted from UTCI_BANDS) against
// the same purple->blue->cyan->green->yellow->orange->red family, sampled
// at each band's new threshold so the shade actually reflects its shifted
// position on the pet scale, rather than reusing a human band's colour at
// a different absolute temperature. Danger keeps the human scale's flat
// alarm red unchanged - it's a deliberate stop-everything colour, not
// part of the smooth gradient.
// -------------------------------------------------------------------
export const PET_BANDS = [
{ max: -28, label: 'Extreme cold', bg: '#7f61ab', fg: '#ffffff' },
{ max: -18, label: 'Arctic', bg: '#957abe', fg: '#ffffff' },
{ max: -8, label: 'Freezing', bg: '#84a0d4', fg: '#ffffff' },
{ max: -3, label: 'Very cold', bg: '#7eb6e2', fg: '#1a1200' },
{ max: 2, label: 'Cold', bg: '#7ec0e8', fg: '#1a1200' },
{ max: 7, label: 'Chilly', bg: '#a8d4ee', fg: '#1a1200' },
{ max: 11, label: 'Cool', bg: '#b5dee9', fg: '#1a1200' },
{ max: 25, label: 'Comfortable', bg: '#a5daa3', fg: '#157a15', themedFg: '#157a15', fontWeight: 700 },
{ max: 32, label: 'Warm', bg: '#f7cd54', fg: '#1a1200' },
{ max: 40, label: 'Caution', bg: '#f5974e', fg: '#1a1200' },
{ max: 52, label: 'Extreme', bg: '#df6f41', fg: '#ffffff' },
{ label: 'Danger', bg: '#880000', fg: '#ffffff', fontWeight: 700, darkenAmt: 0.18, textShadow: '-1px -1px 0 #3a0000, 1px -1px 0 #3a0000, -1px 1px 0 #3a0000, 1px 1px 0 #3a0000', solid: true },
];
export function petCategory(t) {
for (const band of PET_BANDS) {
if (band.max === undefined || t < band.max) return band;
}
}
// -------------------------------------------------------------------
// DAY-TAB BAND VARIANTS - the same bands, one colour swapped.
// -------------------------------------------------------------------
// In the table a cell answers "which stress band is this reading in", and
// green is the right answer for Comfortable: it is the safe band in a
// column of warnings. A day tab answers a different question - "what will
// this day feel like" - and there green reads as damp and mild rather
// than as the best day of the week. So on the tabs (and ONLY on the tabs)
// Comfortable becomes a deep gold: sitting just under Warm's brighter
// yellow, it puts the settled 19-24 -C days on the same warm run as the
// rest of the pleasant half of the scale instead of interrupting it with
// a green step.
//
// Everything else - labels, thresholds, fg colours, the solid Danger
// alarm - is shared with the table palettes above, so the two can never
// drift apart on anything but this one deliberate colour.
// -------------------------------------------------------------------
const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8e554
// The same reasoning, carried down the rest of the HUMAN scale.
//
// UTCI_BANDS runs cyan -> mint -> green -> gold across Cold, Chilly and Cool,
// so a tab crossing from the cold half of the scale to the warm half has to
// travel through green. That hurts twice over: the strip reads as a green
// interruption between the blues and the golds, and because cloud dulls a tab
// toward its own hue, every overcast mid-scale day desaturates into a sickly
// green-grey rather than into something neutral.
//
// On the tabs the scale therefore goes blue -> GREY -> gold, with the neutral
// point sitting exactly where a day has stopped being cold but has not yet
// become pleasant - which is what Chilly means. Nothing passes through green
// in either direction.
//
// Human only. The pet scale is already blue through the equivalent bands
// (pet Cool is #b5dee9, not a green), and its thresholds sit at completely
// different temperatures, so it keeps the Comfortable override alone.
const DAY_TAB_HUMAN = {
Comfortable: DAY_TAB_COMFORTABLE,
Cool: '#ded9c3', // warm oatmeal, one step under Comfortable's gold
Chilly: '#d3d5d2', // the neutral pivot between the warm and cold halves
Cold: '#c2d3dd', // first step back into blue
};
const withTabComfort = (bands) =>
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: DAY_TAB_COMFORTABLE } : b));
export const DAY_TAB_UTCI_BANDS =
UTCI_BANDS.map(b => (DAY_TAB_HUMAN[b.label] ? { ...b, bg: DAY_TAB_HUMAN[b.label] } : b));
export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS);
// Diagonal sweep gradient for a band background hex colour.
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
// then applies a light→mid→dark sweep for depth.
export function bandGradient(hex, darkenAmt = 0.04) {
const n = parseInt(hex.replace('#',''), 16);
const wb = (c) => Math.min(255, Math.round(c + (255 - c) * 0.50));
const [r, g, b] = [wb((n>>16)&255), wb((n>>8)&255), wb(n&255)];
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.30));
const darken = (c) => Math.round(c * (1 - darkenAmt));
const light = '#' + [lighten(r), lighten(g), lighten(b)].map(x => x.toString(16).padStart(2,'0')).join('');
const mid = '#' + [r, g, b].map(x => x.toString(16).padStart(2,'0')).join('');
const dark = '#' + [darken(r), darken(g), darken(b) ].map(x => x.toString(16).padStart(2,'0')).join('');
return `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
}
// -------------------------------------------------------------------
// 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
// 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) {
const base = Math.min(7, 1.4 * Math.pow(precipMm, 0.55) + precipMm * 0.28);
const windMult = 1 + Math.min(0.35, windMs * 0.025);
penalty += base * windMult;
}
if (snowCmH > 0) penalty += Math.min(6, 2.2 + snowCmH * 1.6);
return -Math.round(penalty * 10) / 10;
}
// -------------------------------------------------------------------
// 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'];
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
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.
// 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);
const uvbFr = Math.max(0.005, Math.min(0.15, 0.15 * Math.pow(sinE, 1.6)));
const uvaFr = 1 - uvbFr;
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.
// base = minutes to 1 MED at UV = 1, unprotected, no sunscreen.
// Calibrated to the erythemal-dose science (UVI = 40 x E_er W/m-,
// MED_II ~ 250 J/m-), giving ~150/UV min for fair skin rather than
// the more alarmist consumer "10 min at UV 10" rule of thumb.
// Returns Infinity when UV is 0 (night).
// -------------------------------------------------------------------
export const SKIN_TYPES = {
I: { name: 'I · Very fair', base: 100 },
II: { name: 'II · Fair', base: 150 },
III: { name: 'III · Light', base: 300 },
IV: { name: 'IV · Mid', base: 450 },
V: { name: 'V · Dark', base: 600 },
VI: { name: 'VI · Very dark', base: 750 },
};
export function sunburnMinutes(uv, skinType = 'II') {
if (!uv || uv <= 0) return Infinity;
const base = (SKIN_TYPES[skinType] || SKIN_TYPES.II).base;
return base / uv;
}
export function burnLabel(mins) {
if (!isFinite(mins)) return '—';
if (mins >= 480) return '8h+';
if (mins >= 60) return `${(mins/60).toFixed(1)}h`;
return `${Math.round(mins)}m`;
}
// -------------------------------------------------------------------
// 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 VERTICAL glass area (1.0 = typical car). A coachbuilt
// motorhome has a huge near-vertical windscreen plus cab side
// windows, so it is far from the 0.25 once assumed here.
// roofGlazing - relative HORIZONTAL glass area (rooflights / Heki hatches /
// panoramic glass roof). Gains from these peak at high sun,
// which is exactly when the vertical-glass term is tailing off.
// orientFactor - fraction of the vertical glazing actually facing the sun,
// same idea as BUILDING_TYPES.orientFactor. A parked vehicle
// is NOT aimed at the sun: a car has glass on all four sides
// so a decent share always catches it (0.65), whereas a
// motorhome's glazing is dominated by one big windscreen
// pointing wherever it happened to park (0.50).
// bodyU - effective body/panel conductance into the cabin. Cars are
// thin metal/glass boxes; motorhomes/caravans have insulated
// sandwich panels, commonly around 25-35 mm thick.
// hCabinLoss - effective heat rejection/infiltration from the cabin air.
// Motorhomes/caravans reject heat more SLOWLY than cars (better
// sealed, smaller aperture per unit volume), which is why a
// closed-up van gets as hot as a car despite its insulation.
// ventMult - how much opening the windows multiplies hCabinLoss when
// parked. A car with all windows down flushes far more
// effectively per unit volume than a van with two windows and
// a rooflight open, so this is not a shared constant.
// lagHours - interior thermal time constant. This is a DELAY on reaching
// the hour's equilibrium, not a reduction of it - see the note
// in calcVehicleInteriorTempPass.
// retainedWarmth - occupied insulated living spaces hold heat from previous
// hours, people, appliances, and background heating.
// internalGain - small living-space warmth boost when closed up.
// -------------------------------------------------------------------
export const VEHICLE_TYPES = {
car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.00, roofGlazing: 0.00, orientFactor: 0.65, bodyU: 4.0, hCabinLoss: 11.5, ventMult: 4.0, lagHours: 0.5, retainedWarmth: false, internalGain: 0.0 },
mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.30, roofGlazing: 0.02, orientFactor: 0.65, bodyU: 4.0, hCabinLoss: 13.0, ventMult: 4.0, lagHours: 0.6, retainedWarmth: false, internalGain: 0.0 },
suv: { name: 'SUV / 4x4', albedo: 0.22, glazingArea: 1.10, roofGlazing: 0.02, orientFactor: 0.65, bodyU: 3.8, hCabinLoss: 12.0, ventMult: 4.0, lagHours: 0.6, retainedWarmth: false, internalGain: 0.0 },
truck: { name: 'Truck / HGV Cab', albedo: 0.30, glazingArea: 1.20, roofGlazing: 0.00, orientFactor: 0.60, bodyU: 3.5, hCabinLoss: 11.0, ventMult: 4.0, lagHours: 0.7, retainedWarmth: false, internalGain: 0.0 },
motorhome: { name: 'Motorhome / Campervan', albedo: 0.55, glazingArea: 0.85, roofGlazing: 0.06, orientFactor: 0.50, bodyU: 0.9, hCabinLoss: 7.0, ventMult: 2.5, lagHours: 1.5, retainedWarmth: true, internalGain: 1.2 },
caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.55, roofGlazing: 0.07, orientFactor: 0.55, bodyU: 0.8, hCabinLoss: 6.5, ventMult: 2.4, lagHours: 1.7, retainedWarmth: true, internalGain: 1.2 },
};
// -------------------------------------------------------------------
// VEHICLE SPEEDS - road-speed classes for the cabin heat model.
// -------------------------------------------------------------------
// mph feeds calcVehicleInteriorTemp's speedMph argument. 'static' (0 mph)
// is a parked vehicle and reproduces the original stationary model exactly;
// higher speeds scrub the shell with forced airflow and (windows down) flush
// the cabin toward ambient.
// -------------------------------------------------------------------
export const VEHICLE_SPEEDS = {
static: { name: 'Static', mph: 0 },
urban: { name: 'Urban 20mph', mph: 20 },
aroad: { name: 'A-road 50mph', mph: 50 },
motorway:{ name: 'Motorway 70mph', mph: 70 },
};
// -------------------------------------------------------------------
// 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
// 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
// solar gain in summer, more heat loss in winter.
// 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.
// 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.
// Higher = more air changes per hour.
// 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
// 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 },
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30, solarScale: 0.10, baseTemp: 16.0, internalGain: 0.7, retainedScale: 0.25 },
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25, solarScale: 0.08, baseTemp: 15.5, internalGain: 0.6, retainedScale: 0.40 },
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.45 },
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20, solarScale: 0.11, baseTemp: 17.0, internalGain: 0.9, retainedScale: 0.45 },
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 },
office: { name: 'Office Block', uWall: 0.25, lagHours: 2, glazingRatio: 0.45, gValue: 0.55, orientFactor: 0.65, curtainBlock: 0.50, ventAlpha: 0.15, solarScale: 0.055, baseTemp: 18.5, internalGain: 2.5, retainedScale: 0.30 },
};
// -------------------------------------------------------------------
// FUR COLORS - presets for the fur surface temperature model (Pets profile).
// -------------------------------------------------------------------
// albedo - how much solar radiation the coat reflects (0 = absorbs almost
// all of it, 1 = reflects almost all of it). Feeds calcFurSurfaceTemp.
// -------------------------------------------------------------------
export const FUR_COLORS = {
black: { name: 'Black / Dark', albedo: 0.05 },
brown: { name: 'Brown / Tabby', albedo: 0.15 },
golden: { name: 'Golden / Tan', albedo: 0.25 },
white: { name: 'White / Pale', albedo: 0.40 },
};
// -------------------------------------------------------------------
// 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
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).
//
// Each day gets its own shade, interpolated between two endpoint
// colours. To re-skin the gradient (say, blue-to-purple instead of
// sunset), just change the four RGB endpoint arrays below.
//
// bg = tab background (lighter on day 0, darker on day 13)
// edge = active-tab underline + slim border on the banner
// tint = soft wash on the confidence banner above the table
// label = qualitative zone name shown in the banner ("Golden hour")
//
// 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.
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
const edgeEnd = [196, 115, 64]; // #c47340 burnt umber
// Linear interpolation between the two endpoints.
const lerp = (a, b) => Math.round(a + t * (b - a));
const mix = (s, e) => [lerp(s[0], e[0]), lerp(s[1], e[1]), lerp(s[2], e[2])];
const [br, bg, bb] = mix(bgStart, bgEnd);
const [er, eg, eb] = mix(edgeStart, edgeEnd);
return {
bg: `rgb(${br}, ${bg}, ${bb})`,
edge: `rgb(${er}, ${eg}, ${eb})`,
tint: `rgba(${er}, ${eg}, ${eb}, 0.22)`,
// Qualitative confidence label (camera-focus metaphor -
// on-brand for SunScope, and instantly readable).
label: i < 3 ? 'Pin-sharp' // days 1-3 highest skill
: i < 7 ? 'Sharp' // days 4-7 solid
: i < 10 ? 'Soft focus' // days 8-10 trends only
: 'Blurry', // days 11-14 outlook only
};
}
// -------------------------------------------------------------------
// 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;
const ref = 2451549.2303;
let p = ((JD - ref) % syn) / syn;
if (p < 0) p += 1;
return p;
}
export function moonGlyph(p) {
// +0.5 offset rounds to nearest phase (avoids flickering on exact boundaries)
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p * 8 + 0.5) % 8];
}
// -------------------------------------------------------------------
// 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.
// Both share the same deep blue zenith at high elevations.
// -------------------------------------------------------------------
// 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
[ 10, '#7cc5ec', '#c8e3ee'], // low sun: pale blue sky
[ 30, '#5bb8e8', '#9fd3ef'], // mid-day blue
[ 60, '#2e8fd4', '#7cc8ef'], // high noon: rich deep blue
[ 90, '#1a7abf', '#60b8e8'], // zenith (sun directly overhead)
];
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
[ 10, '#7cc5ec', '#c8e3ee'],
[ 30, '#5bb8e8', '#9fd3ef'],
[ 60, '#2e8fd4', '#7cc8ef'],
[ 90, '#1a7abf', '#60b8e8'],
];
/* hexToRgb is the exported one from the weather tint palette above. */
function rgbToHex([r, g, b]) {
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
}
function lerpRgb(a, b, t) {
return a.map((v, i) => v + t * (b[i] - v));
}
function interpolateSkyKeys(keys, e) {
// Clamp to key range
if (e <= keys[0][0]) return { top: keys[0][1], bot: keys[0][2] };
if (e >= keys[keys.length - 1][0]) {
const k = keys[keys.length - 1];
return { top: k[1], bot: k[2] };
}
// Find bracketing keyframes
let lo = keys[0], hi = keys[1];
for (let i = 1; i < keys.length; i++) {
if (keys[i][0] >= e) { lo = keys[i - 1]; hi = keys[i]; break; }
}
const t = (e - lo[0]) / (hi[0] - lo[0]);
const top = rgbToHex(lerpRgb(hexToRgb(lo[1]), hexToRgb(hi[1]), t));
const bot = rgbToHex(lerpRgb(hexToRgb(lo[2]), hexToRgb(hi[2]), t));
return { top, bot };
}
export function skyGradientForElev(e, isRising) {
return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
}
// --- 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.
// -------------------------------------------------------------------
// 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
if (e > 10) return { top: '#7ab846', bot: '#4d802c' }; // mid-day
if (e > 3) return { top: '#b8a83e', bot: '#7a6a26' }; // golden-hour glow
if (e > -3) return { top: '#c08048', bot: '#7a4a2a' }; // sunrise/sunset embers
if (e > -8) return { top: '#665884', bot: '#3e3556' }; // civil twilight purple
if (e > -14) return { top: '#363356', bot: '#1c1a34' }; // nautical night
return { top: '#201d3a', bot: '#0d0b20' }; // astronomical night
}
// --- scoreFillColor --------------------------------------------------------
// Colour for the "Best Days Out" score bar, so the hue carries the same
// message as the length: red at 0, amber through the middle, green at 100.
//
// Straight hue interpolation across the red-yellow-green arc (0 deg to 120)
// rather than blending between three fixed hex stops - the blend route in RGB
// dips through brown around the midpoint, which reads as a fault rather than
// a middling day. Saturation and lightness stay put so the bar keeps one
// weight across the range instead of the yellow end glaring.
export function scoreFillColor(score) {
const pct = Math.max(0, Math.min(100, score ?? 0));
return `hsl(${Math.round(pct * 1.2)} 68% 44%)`;
}
// --- titleCaseText ---------------------------------------------------------
// Insight-panel labels and values were written ad hoc over time, so the rail
// mixed "Peak felt temp" with "Air quality" with "+9.0° warmer". Rather than
// hand-editing every string (many are built at runtime, and app.js matches
// some of them by exact text when ordering rows), the panels title-case at
// render time.
//
// Only capitalises a letter that STARTS a word - one preceded by nothing,
// whitespace or punctuation. A letter run glued to a number is a unit, not a
// word, so "6am" and "10°C" survive as written rather than becoming "6Am".
// Letters already capitalised are never touched, which keeps UV and AQI whole.
export function titleCaseText(s) {
return String(s ?? '').replace(
/(^|[^A-Za-z0-9°'])([a-z])/g,
(_, before, ch) => before + ch.toUpperCase(),
);
}