Files
sunscope/assets/js/utils.js
T
fraxle 916efca55e 3.5
UI Overhaul
Profiles now in popup
Current profile always on screen
Hidden columns only when edit neeeded
Add pulldowns in to profile config
Customised the day tabs for vehicle, indoor and pet
2026-07-18 15:45:20 +01:00

476 lines
27 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;
}
}
// -------------------------------------------------------------------
// 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;
}
}
// 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 sun-exposed glass area (1.0 = typical car)
// bodyU - effective body/panel conductance into the cabin. Cars are
// thin metal/glass boxes; motorhomes/caravans have insulated
// sandwich panels, commonly around 25-35 mm thick.
// hCabinLoss - effective heat rejection/infiltration from the cabin air.
// thermalMass - lower values mean the interior warms more slowly in the hour.
// retainedWarmth - occupied insulated living spaces hold heat from previous
// 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.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 },
suv: { name: 'SUV / 4x4', albedo: 0.22, glazingArea: 1.1, bodyU: 3.8, hCabinLoss: 19, thermalMass: 0.95, retainedWarmth: false, internalGain: 0.0 },
truck: { name: 'Truck / HGV Cab', albedo: 0.30, glazingArea: 1.2, bodyU: 3.5, hCabinLoss: 18, thermalMass: 0.90, retainedWarmth: false, internalGain: 0.0 },
motorhome: { name: 'Motorhome / Campervan', albedo: 0.55, glazingArea: 0.25, bodyU: 0.9, hCabinLoss: 12, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 },
caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 },
};
// -------------------------------------------------------------------
// 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'],
];
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
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
}