Beta5
New code structure Added profiles Fixed skin pulldown Added rain images
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// 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 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 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
|
||||
}
|
||||
Reference in New Issue
Block a user