New big and small scope sunset and sunrise simulation New calculations for the day tabs Added missing icons to simple view New record popups Week ahead description popups Camping profile Tweak to some of the indoor tems
776 lines
44 KiB
JavaScript
776 lines
44 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.
|
|
// -------------------------------------------------------------------
|
|
// bandRampRgb() draws a straight line between each pair of neighbouring
|
|
// anchors, so three anchors only look like one smooth ramp if the middle one
|
|
// sits ON the line between the other two. A hand-picked "one step under
|
|
// Warm" gold does not: it already carries Warm's full saturation, so Cool
|
|
// (15C, oatmeal) to Comfortable (19C) is an abrupt jump to solid gold and
|
|
// Comfortable to Warm (24C) is then almost flat - which is exactly the "very
|
|
// yellow" plateau a mild week (19-24C highs) sits in. Setting Comfortable to
|
|
// the actual RGB midpoint between Cool's oatmeal and Warm's yellow removes
|
|
// the kink, so the same mild week instead reads as a continuous fade.
|
|
const DAY_TAB_HUMAN_COMFORTABLE = '#eade92';
|
|
// Same fix, recomputed for the pet scale's own Cool (11C, #b5dee9) and Warm
|
|
// (32C, #f7cd54) anchors - the pet ramp spans different thresholds, so the
|
|
// human midpoint would land in the wrong place on it.
|
|
const DAY_TAB_PET_COMFORTABLE = '#e1d386';
|
|
|
|
// 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_HUMAN_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, comfortableBg) =>
|
|
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: comfortableBg } : 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, DAY_TAB_PET_COMFORTABLE);
|
|
|
|
// 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 two-node indoor temperature model.
|
|
// -------------------------------------------------------------------
|
|
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass
|
|
// (see physics.js). Every building is modelled EMPTY - no heating, no
|
|
// occupants, only standby appliances. Heavy masonry swings ~3-4 C a day and
|
|
// ratchets up over a heatwave; lightweight timber and top-floor flats run
|
|
// hottest; in a prolonged hard frost empty houses settle near 0 C, with
|
|
// terraces and flats held up by heated neighbours.
|
|
//
|
|
// lagHours h Room AIR response time - how fast the air follows its
|
|
// equilibrium. Short for every building; the slow part of
|
|
// the response is the mass node below.
|
|
// massHours h Air-to-mass exchange time constant of walls/floors.
|
|
// massCoupling - How strongly the mass holds the room air (0-1) versus the
|
|
// outdoor pull through glass, fabric and leakage. Higher =
|
|
// heavier/better insulated = smaller daily swing. Effective
|
|
// outdoor time constant is massHours / (1 - massCoupling):
|
|
// ~17 h timber ... ~30 h brick ... ~53 h modern and stone.
|
|
// glazingRatio - Fraction of floor area that is window (tent: sunlit fabric).
|
|
// gValue - Solar heat gain coefficient of glazing. ~0.63 older double
|
|
// glazing; ~0.50 typical UK low-e double; ~0.30 solar-control.
|
|
// orientFactor - Fraction of windows facing the sun at any given time.
|
|
// 0.5 = random orientation; 0.7 = south-facing conservatory.
|
|
// curtainBlock - Fraction of solar gain blocked by the managed heat routine.
|
|
// Internal curtains/blinds only stop ~40-55% because the heat
|
|
// is already through the glass (external shutters ~80%+).
|
|
// curtainInsul - Share of OUTDOOR heat loss saved by closing curtains after
|
|
// dark in the managed cool routine (windows carry ~25-40% of
|
|
// it; lined curtains cut that by about a third).
|
|
// ventAlpha - Per-hour blend toward outdoor air when windows are opened
|
|
// in the managed heat routine. Higher = more air changes.
|
|
// solarScale - Converts effective window solar gain into a temperature
|
|
// lift on the room's outdoor-driven target. Higher for
|
|
// better-insulated shells (same gain, less heat loss).
|
|
// groundCoupling - Share of the outdoor heat loss that goes through the
|
|
// floor to the ground (seasonal UK ground temp) instead of
|
|
// to outside air. 0 for flats above ground and tents.
|
|
// neighbourCoupling - Share lost through party walls/floors to occupied
|
|
// neighbours, who never let their homes fall below 18 C.
|
|
// Highest for flats and terraces; 0 for detached/tents.
|
|
// internalGain - Standby gain of the EMPTY building - fridge, router,
|
|
// standby loads (C). Larger where the shell loses less heat.
|
|
// -------------------------------------------------------------------
|
|
export const BUILDING_TYPES = {
|
|
brick: { name: 'Brick (typical)', lagHours: 1.5, massHours: 6, massCoupling: 0.80, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.55, curtainInsul: 0.12, ventAlpha: 0.25, solarScale: 0.10, groundCoupling: 0.15, neighbourCoupling: 0.10, internalGain: 0.4 },
|
|
// Modern: airtight and insulated, typical low-e double glazing - loses heat
|
|
// slowly, so it holds warmth in a cold snap and accumulates heat over a
|
|
// long heatwave.
|
|
modern: { name: 'Modern / Well insulated', lagHours: 1.5, massHours: 8, massCoupling: 0.85, glazingRatio: 0.20, gValue: 0.50, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.10, ventAlpha: 0.20, solarScale: 0.14, groundCoupling: 0.12, neighbourCoupling: 0.10, internalGain: 1.0 },
|
|
victorian: { name: 'Victorian Terrace', lagHours: 1.5, massHours: 6, massCoupling: 0.75, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.55, curtainInsul: 0.15, ventAlpha: 0.30, solarScale: 0.10, groundCoupling: 0.08, neighbourCoupling: 0.20, internalGain: 0.3 },
|
|
stone: { name: 'Stone / Granite Cottage', lagHours: 2, massHours: 8, massCoupling: 0.85, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.10, ventAlpha: 0.25, solarScale: 0.08, groundCoupling: 0.20, neighbourCoupling: 0.00, internalGain: 0.3 },
|
|
timber: { name: 'Timber Frame / New Build', lagHours: 1, massHours: 6, massCoupling: 0.65, glazingRatio: 0.22, gValue: 0.50, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.12, ventAlpha: 0.35, solarScale: 0.08, groundCoupling: 0.12, neighbourCoupling: 0.05, internalGain: 0.6 },
|
|
flat: { name: 'Top-floor Flat', lagHours: 1, massHours: 5, massCoupling: 0.70, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.12, ventAlpha: 0.20, solarScale: 0.11, groundCoupling: 0.00, neighbourCoupling: 0.25, internalGain: 0.6 },
|
|
conservatory:{ name: 'Conservatory / Sun Room', lagHours: 0.5, massHours: 3, massCoupling: 0.30, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, curtainInsul: 0.25, ventAlpha: 0.50, solarScale: 0.045, groundCoupling: 0.10, neighbourCoupling: 0.00, internalGain: 0.0 },
|
|
office: { name: 'Office Block', lagHours: 1, massHours: 8, massCoupling: 0.70, glazingRatio: 0.45, gValue: 0.55, orientFactor: 0.65, curtainBlock: 0.40, curtainInsul: 0.05, ventAlpha: 0.15, solarScale: 0.055, groundCoupling: 0.08, neighbourCoupling: 0.00, internalGain: 1.0 },
|
|
// Tent: glazingRatio stands in for the sun-absorbing fabric area, not windows,
|
|
// giving ~+12 °C over ambient at 800 W/m² when closed up. Almost no mass;
|
|
// "managed" means doors open / shaded, curtainInsul 0 (nothing to close).
|
|
// Empty tent: no body heat, so at night it sits near outdoor air temp.
|
|
tent: { name: 'Tent', lagHours: 0.5, massHours: 1, massCoupling: 0.05, glazingRatio: 0.20, gValue: 0.85, orientFactor: 0.60, curtainBlock: 0.30, curtainInsul: 0.00, ventAlpha: 0.60, solarScale: 0.15, groundCoupling: 0.00, neighbourCoupling: 0.00, internalGain: 0.0 },
|
|
};
|
|
|
|
// -------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SUN LIGHT STATE - physics-lite sun colour, glow and afterglow.
|
|
// -------------------------------------------------------------------
|
|
// Shared by SkyScope (hourly mini scopes) and ScopeReticle (big scope)
|
|
// so both rise/set identically. Inputs are one forecast row's values;
|
|
// hourly rows are computed at the top of the hour (17:00, not 17:10).
|
|
//
|
|
// Colour: the lower the sun, the more air its light crosses (air mass,
|
|
// Kasten-Young). Each RGB channel is dimmed by Rayleigh scattering (blue
|
|
// far more than red) plus haze aerosol estimated from visibility (or
|
|
// humidity when visibility is missing), so the sun runs white -> yellow
|
|
// -> orange -> red, and redder still on hazy evenings.
|
|
//
|
|
// Returns:
|
|
// core - sun disk colour (hex)
|
|
// glow - halo / afterglow colour (hex)
|
|
// diskAlpha - disk opacity (cloud in front of the sun dims it)
|
|
// skyGlow - 0..1 strength of the horizon glow in the sky
|
|
// groundGlow - 0..1 strength of the warm wash on the ground
|
|
// haze - 0..1, widens and softens the glow
|
|
// -------------------------------------------------------------------
|
|
const SUN_TAU_RAYLEIGH = [0.045, 0.09, 0.19]; // R,G,B optical depth at air mass 1 (~650/550/450nm)
|
|
const SUN_AEROSOL_SPECTRAL = [0.80, 1.0, 1.30]; // aerosol depth per channel relative to 550nm (Angstrom ~1.3)
|
|
const AEROSOL_LAYER_KM = 1.2; // haze layer depth used to turn visibility into optical depth
|
|
const SUN_DISPLAY_GAMMA = 0.5; // perceptual compression so low sun reads orange, not pure red
|
|
|
|
// Smooth 0..1 ramp between a and b.
|
|
function smoothstep(a, b, x) {
|
|
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
// Relative path length through the atmosphere (1 overhead, ~38 at the horizon).
|
|
function airMass(elev) {
|
|
const h = Math.max(0, elev);
|
|
return 1 / (Math.sin(h * Math.PI / 180) + 0.50572 * Math.pow(h + 6.07995, -1.6364));
|
|
}
|
|
|
|
export function sunLightState({ elev, isRising = false, cc = null, ccLow = null, ccMid = null,
|
|
ccHigh = null, visKm = null, rh = null }) {
|
|
// Haze: visibility -> aerosol optical depth (Koschmieder 3.912/vis).
|
|
// Without visibility, guess it from humidity (moist air = hazier).
|
|
let vis = visKm;
|
|
if (vis == null) vis = rh != null ? Math.max(4, Math.min(40, 40 - Math.max(0, rh - 50) * 0.75)) : 25;
|
|
const aod = Math.max(0.05, Math.min(1.5, AEROSOL_LAYER_KM * 3.912 / Math.max(0.5, vis)));
|
|
const haze = Math.max(0, Math.min(1, (aod - 0.15) / 0.8));
|
|
|
|
// Transmitted sunlight per channel, normalised so the brightest is 255.
|
|
const am = airMass(elev);
|
|
const trans = SUN_TAU_RAYLEIGH.map((tau, i) => Math.exp(-(tau + aod * SUN_AEROSOL_SPECTRAL[i]) * am));
|
|
const sunRgb = trans.map(t => 255 * Math.pow(t / trans[0], SUN_DISPLAY_GAMMA));
|
|
|
|
// Cloud: low cloud blocks the sun and the horizon light; mid/high cloud
|
|
// over a clear low sky catches the colour ("fire sky").
|
|
const low = ccLow != null ? ccLow : (cc != null ? cc * 0.6 : 0);
|
|
const mid = ccMid != null ? ccMid : 0;
|
|
const high = ccHigh != null ? ccHigh : 0;
|
|
const lowBlock = Math.min(1, low / 100);
|
|
const block = Math.min(1, (low * 0.8 + mid * 0.45 + high * 0.15) / 100);
|
|
const fire = Math.min(1, (mid + high) / 80) * (1 - lowBlock);
|
|
|
|
// Elevation envelopes: the sky glow builds as the sun drops below ~14 deg,
|
|
// peaks at the horizon and lingers through civil twilight (to -9 deg).
|
|
// The ground only gets direct warm light while the sun is up, so its
|
|
// wash fades out quickly once the sun has gone (by -4 deg).
|
|
const lowSun = 1 - smoothstep(2, 14, elev);
|
|
const skyEnv = lowSun * smoothstep(-9, -1, elev);
|
|
const groundEnv = (1 - smoothstep(3, 14, elev)) * smoothstep(-4, 0.5, elev);
|
|
const skyGlow = Math.min(1, 0.75 * skyEnv * (1 - 0.7 * lowBlock) * (1 + 0.45 * fire));
|
|
const groundGlow = Math.min(1, 0.6 * groundEnv * (1 - 0.85 * lowBlock));
|
|
|
|
// Glow colour: the sun's own colour, shifting rose (dawn) or violet (dusk)
|
|
// as the sun sinks deeper, and pinker under a fire sky.
|
|
const sunHex = rgbToHex(sunRgb);
|
|
const depth = Math.max(0, Math.min(1, -elev / 8));
|
|
let glow = rgbToHex(lerpRgb(sunRgb, hexToRgb(isRising ? '#c8607a' : '#a85a9e'), depth * 0.8));
|
|
glow = rgbToHex(lerpRgb(hexToRgb(glow), hexToRgb('#ff5f6d'), fire * skyEnv * 0.35));
|
|
|
|
// Disk: overexposed (near white) high up, true colour near the horizon.
|
|
const core = rgbToHex(lerpRgb(sunRgb, [255, 255, 255], 0.15 + 0.45 * (1 - lowSun)));
|
|
|
|
return { core, glow, sun: sunHex, diskAlpha: 1 - 0.8 * block, skyGlow, groundGlow, haze };
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 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(),
|
|
);
|
|
}
|