// ------------------------------------------------------------------------ // 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; } } // ------------------------------------------------------------------- // 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 pick ONE dimension by priority (heat -> snow -> rain -> // cloud -> sun) and only vary its shade; these helpers are the shade // ramps for the non-temperature dimensions. Temperature-driven tints // come from utciCategory().bg / petCategory().bg instead. // ------------------------------------------------------------------- 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; } } // 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 }