// ════════════════════════════════════════════════════════════════════════ // utils.js — Pure helper functions and lookup tables. // // Exports: // utciCategory(u) UTCI stress band → {label,bg,fg} // precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor // windCompass8(deg) bearing → {label, snapped} // uvSplit(uv, elevDeg) total UV → {uvA, uvB} // SKIN_TYPES Fitzpatrick skin type table // sunburnMinutes(uv, skinType) minutes to MED // burnLabel(mins) formats burn time as "12m"/"1.5h" // VEHICLE_TYPES vehicle presets for cabin heat model // BUILDING_TYPES building presets for indoor heat model // cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast' // confidenceBand(i) day-tab gradient + label // moonPhaseFraction(date) 0..1 synodic phase // moonGlyph(p) phase fraction → emoji // skyGradientForElev(e, isRising) solar elevation + direction → {top,bot} sky gradient // grassFillForElev(e) solar elevation → {top,bot} hex // ════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════ // UTCI THERMAL STRESS BANDS — the coloured pills in the table. // ─────────────────────────────────────────────────────────────────── // To recolour any band, change its bg/fg hex code. To shift the // boundary between bands (e.g. make "Comfortable" wider), change the // `if (u < …)` thresholds. Order matters — they're checked top-down. // ═══════════════════════════════════════════════════════════════════ export function utciCategory(u) { if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', fg: '#fff' }; if (u < -27) return { label: 'Very strong cold', bg: '#23408f', fg: '#fff' }; if (u < -13) return { label: 'Arctic', bg: '#3f73c4', fg: '#fff' }; if (u < 0) return { label: 'Freezing', bg: '#7eb0e0', fg: '#1a1612' }; if (u < 9) return { label: 'Cold', bg: '#bcd9ec', fg: '#1a1612' }; if (u < 18) return { label: 'Chilled', bg: '#c8dcc0', fg: '#1a1612' }; if (u < 26) return { label: 'Comfortable', bg: '#4a8a3a', fg: '#fff' }; if (u < 32) return { label: 'Moderate heat', bg: '#e8c547', fg: '#1a1612' }; if (u < 38) return { label: 'Strong heat', bg: '#dc8a3a', fg: '#1a1612' }; if (u < 46) return { label: 'Very strong heat', bg: '#c44a3a', fg: '#fff' }; return { label: 'Extreme heat', bg: '#7a1a1a', fg: '#fff' }; } // ═══════════════════════════════════════════════════════════════════ // 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. // Numbers are the well-known "unprotected, midday, no sunscreen" // reference times at UV = 1. Returns Infinity when UV is 0 (night). // ═══════════════════════════════════════════════════════════════════ export const SKIN_TYPES = { I: { name: 'I · Very fair', base: 67 }, II: { name: 'II · Fair', base: 100 }, III: { name: 'III · Light', base: 200 }, IV: { name: 'IV · Mid', base: 300 }, V: { name: 'V · Dark', base: 400 }, VI: { name: 'VI · Very dark', base: 500 }, }; 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 }, 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 }, }; // ═══════════════════════════════════════════════════════════════════ // 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 }, }; // ═══════════════════════════════════════════════════════════════════ // 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 = 2451550.1; let p = ((JD - ref) % syn) / syn; if (p < 0) p += 1; return p; } export function moonGlyph(p) { 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); } // Convenience flat colour for contexts needing a single fill. export function skyFillForElev(e, isRising = false) { return skyGradientForElev(e, isRising).bot; } // Grass palette — mirrors sky but for the ground strip. 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 }