Files
sunscope/assets/js/utils.js
T
fraxle e18e128a34 Beta5
New code structure
Added profiles
Fixed skin pulldown
Added rain images
2026-05-13 15:16:45 +01:00

248 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ════════════════════════════════════════════════════════════════════════
// 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"
// 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
// skyFillForElev(e) solar elevation → sky hex colour
// 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: '#6ab05a', 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`;
}
// ═══════════════════════════════════════════════════════════════════
// 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 13 highest skill
: i < 7 ? 'Sharp' // days 47 solid
: i < 10 ? 'Soft focus' // days 810 trends only
: 'Blurry', // days 1114 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 FILL — colour inside the little circle, based on sun elevation.
// ───────────────────────────────────────────────────────────────────
// > 30° high noon (bright blue)
// > 10° mid-sky (pale blue)
// > 3° morning/afternoon (warm gold)
// > -3° golden hour / sunset (deep amber)
// > -8° civil twilight (dusky lilac)
// > -14° nautical twilight (deep blue-violet)
// else night (indigo)
// To shift "when sunset starts" visually, tweak the elevation
// thresholds. To recolour: replace the hex codes.
// ═══════════════════════════════════════════════════════════════════
export function skyFillForElev(e) {
if (e > 30) return '#9fd3ef';
if (e > 10) return '#c8e3ee';
if (e > 3) return '#ffd596';
if (e > -3) return '#f59b6e';
if (e > -8) return '#9279b0';
if (e > -14) return '#3a2f60';
return '#1a1538';
}
// Grass palette — mirrors skyFillForElev but for the ground.
// `top` = grass blade tips (catches sky light), `bot` = soil shadow.
export function grassFillForElev(e) {
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright noon grass
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
}