Files
sunscope/assets/js/components.js
T
fraxle 71860b9dd9 5.4.0
Restructure
New Journal entries
Colour tabs update with wind
2026-08-31 15:12:45 +01:00

1486 lines
111 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.
// ------------------------------------------------------------------------
// components.js - Preact UI components (SVG widgets and controls).
//
// All components use the html'' tagged template from htm + Preact.
// No external state; they depend only on utils.js and events.js.
//
// Exports (in order of appearance):
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
// VentPill({ checked, onChange, label, title }) checkbox fused to pill
// RowStepper({ value, onChange, showLabel, label }) table row-interval stepper
// SkyScope({ elev, dt, glob, size }) small porthole per hour
// WindVane({ bearing, size }) compass-arrow widget
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
// ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent, utciEnvShort })
// large UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon
// ------------------------------------------------------------------------
import { h, Fragment } from '../vendor/preact.js';
import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
skyGradientForElev, skyFillForElev, grassFillForElev,
moonPhaseFraction,
} from './utils.js';
import { getLensOverlaySVG } from './events.js';
const html = htm.bind(h);
// --- Live-weather scope assets/helpers -----------------------------------
// Soft transparent cloud sprite, layered + drifted inside the big lens.
const CLOUD_IMG = 'assets/images/tinycloud.png';
// Linear blend between two #rrggbb colours (t = 0 → a, 1 → b). Used to grey
// out / darken the lens sky as cloud cover and precipitation increase.
function _hexRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function blendHex(a, b, t) {
t = Math.max(0, Math.min(1, t));
const A = _hexRgb(a), B = _hexRgb(b);
return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join('');
}
// Tiny seeded PRNG (mulberry32). Deterministic so the scattered ground
// objects keep the same positions on every re-render (no jitter).
function seededRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), 1 | t);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Per-environment ground: a daytime gradient (top = horizon → bottom =
// foreground; some split into bands), a night target colour, and an object
// set. All colours are run through one cycle model at render time (darken by
// elevation, tint toward the live sky, grey out in bad weather, whiten land
// when snowing). `obj.bands` constrains where objects may sit (e.g. beach
// palms on sand, river reeds on the banks not the water).
const GROUND_SPEC = {
open: { night: '#0e160b', grad: [[0, '#6f9e3e'], [1, '#3f6a24']],
obj: { types: ['hedge', 'bush', 'oak'], base: 18, n: 7, sizeMul: 1.15, seed: 1011, bands: [[0.14, 0.92]] } },
forest: { night: '#0d120a', grad: [[0, '#3f5a2e'], [0.5, '#4a4628'], [1, '#3a2c19']],
obj: { types: ['oak'], base: 30, n: 14, sizeMul: 1.7, seed: 3031, bands: [[0.04, 0.94]], randomX: true } },
alpine: { night: '#11141b', snowGround: true, grad: [[0, '#787c82'], [1, '#494c52']],
obj: { types: ['pine'], base: 20, n: 7, sizeMul: 1.4, seed: 7073, bands: [[0.12, 0.92]], mountains: true } },
urban: { night: '#13151b', grad: [[0, '#9a9da2'], [1, '#595c61']],
obj: { skyline: true, seed: 2023 } },
beach: { night: '#0e1620', grad: [[0, '#3f7a92'], [0.4, '#5a90a4'], [0.46, '#e7d3a0'], [1, '#cdaf78']],
obj: { types: ['palm', 'parasol'], base: 26, n: 6, sizeMul: 1.3, seed: 4041, bands: [[0.5, 0.92]] } },
openwater: { night: '#0b1a24', grad: [[0, '#4a7e98'], [1, '#214458']],
obj: { types: ['boat'], base: 22, n: 5, sizeMul: 1.2, seed: 6061, bands: [[0.15, 0.9]] } },
desert: { night: '#19140f', grad: [[0, '#d9bd85'], [1, '#b6945c']],
obj: { types: ['cactus'], base: 24, n: 6, sizeMul: 1.3, seed: 8083, bands: [[0.12, 0.9]] } },
river: { night: '#0d130a', grad: [[0, '#4d6c30'], [0.10, '#3f5a28'], [0.15, '#42738b'], [0.44, '#356074'], [0.50, '#4d6c30'], [1, '#345020']],
obj: { types: ['reed', 'oak'], base: 17, n: 8, sizeMul: 1.3, seed: 5053, bands: [[0.03, 0.09], [0.58, 0.92]] } },
};
// Daytime "material" colour per scattered object type — trees green, cacti
// desert-green, parasol warm, boat sail-coloured, etc. Run through the cycle
// (objTone) at render time so they darken at night, warm at sunset, etc.
const MATERIAL = {
oak: '#0e2208', pine: '#091410', bush: '#1e380e', hedge: '#122808',
cactus: '#1e4016', palm: '#102a10', parasol: '#bb5a46', reed: '#161e04',
boat: '#ccc4ad',
};
const MOON_TEX = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAFEGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjYtMDUtMjdUMTQ6NDI6MDUrMDE6MDAiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDI2LTA1LTI3VDE0OjQ0OjA2KzAxOjAwIgogICB4bXA6TWV0YWRhdGFEYXRlPSIyMDI2LTA1LTI3VDE0OjQ0OjA2KzAxOjAwIgogICBwaG90b3Nob3A6RGF0ZUNyZWF0ZWQ9IjIwMjYtMDUtMjdUMTQ6NDI6MDUrMDE6MDAiCiAgIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiCiAgIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjEyOCIKICAgZXhpZjpQaXhlbFlE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';
// -------------------------------------------------------------------
// CUSTOMSELECT - cross-platform styled dropdown pill.
// -------------------------------------------------------------------
// props:
// value - current value string
// options - array of { value, label, disabled }
// onChange - fn(newValue)
// hideLabel - label shown when value === 'off' (e.g. 'Burn')
// hidingLabel - label shown for the 'off' option when active (e.g. 'Hide Burn')
// isOn - bool - whether the pill shows as active (brass)
// groupedLeft - bool - fuse right side with a VentPill
// isLastChild - bool - restore right border-radius when no sibling follows
// -------------------------------------------------------------------
// Finds the nearest scrollable ancestor (e.g. the profile/config bottom
// sheet's scrolling body) so dropdown panels can size themselves to the
// space actually available before that ancestor clips them, rather than
// guessing against the full viewport and getting cropped invisibly.
function getScrollParent(el) {
let node = el && el.parentElement;
while (node && node !== document.body) {
const style = getComputedStyle(node);
if (/(auto|scroll)/.test(style.overflowY)) return node;
node = node.parentElement;
}
return document.documentElement;
}
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild, grpClass, sceneStyle, sceneContent }) {
const [open, setOpen] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const [maxPanelHeight, setMaxPanelHeight] = useState(null);
const [panelLeft, setPanelLeft] = useState(null);
const wrapRef = useRef(null);
const panelRef = useRef(null);
// Close on outside click or Escape
useEffect(() => {
if (!open) return;
const onDown = (e) => {
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
};
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('touchstart', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('touchstart', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
// After the panel renders, centre it under/over the button, then clamp
// that position so it never spills past the real clipping ancestor's
// edges (e.g. the profile/config bottom sheet's scrolling body, which
// also clips horizontally whenever it clips vertically — see
// getScrollParent above — so comparing against the browser viewport alone
// isn't enough). Also flip to whichever side (up/down) has more room, and
// cap the panel's height to whatever space is actually available on that
// side so a long list scrolls internally instead of being cropped
// invisibly.
// useLayoutEffect (not useEffect) so this measure-and-position happens
// before the browser paints the first frame — otherwise the panel visibly
// flashes in its unclamped spot for one frame before correcting itself.
useLayoutEffect(() => {
if (!open) { setFlipUp(false); setMaxPanelHeight(null); setPanelLeft(null); return; }
if (!panelRef.current || !wrapRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
const wrapRect = wrapRef.current.getBoundingClientRect();
const bound = getScrollParent(wrapRef.current).getBoundingClientRect();
const desiredLeft = wrapRect.left + wrapRect.width / 2 - rect.width / 2;
const minLeft = bound.left + 4;
const maxLeft = bound.right - 4 - rect.width;
const clampedLeft = Math.max(minLeft, Math.min(desiredLeft, maxLeft));
setPanelLeft(clampedLeft - wrapRect.left);
const spaceBelow = bound.bottom - wrapRect.bottom;
const spaceAbove = wrapRect.top - bound.top;
const overflowsDown = rect.bottom > bound.bottom - 8;
const flip = overflowsDown && spaceAbove > spaceBelow;
setFlipUp(flip);
setMaxPanelHeight(Math.max(80, Math.floor((flip ? spaceAbove : spaceBelow) - 12)));
}, [open]);
// Build the label shown on the button
const activeOpt = options.find(o => o.value === value);
const btnLabel = buttonLabel || (isOn
? (activeOpt ? activeOpt.label : hideLabel)
: hideLabel);
const btnClass = [
'cs-btn',
grpClass || '',
isOn ? 'on' : '',
groupedLeft ? 'grouped-left' : '',
(groupedLeft && isLastChild) ? 'last-child' : '',
].filter(Boolean).join(' ');
// Longer lists (SunSoak, Indoors, ...) lay out as two columns so every
// option is visible at a glance instead of relying on people to notice
// they need to scroll a cropped list.
const useColumns = options.length > 5;
return html`
<span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}>
<button
class=${btnClass}
onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}
type="button"
>
${sceneStyle !== undefined && html`<span class="cs-btn-scene" style=${sceneStyle}>${sceneContent || ''}</span>`}
<span class="cs-btn-overlay"></span>
<span class="cs-btn-label">${btnLabel}</span>
<span class="cs-arrow"></span>
</button>
${open && html`
<div class=${`cs-panel${flipUp ? ' cs-panel--up' : ''}${useColumns ? ' cs-panel--cols' : ''}`} ref=${panelRef} role="listbox"
style=${{
...(panelLeft != null ? { left: panelLeft + 'px' } : {}),
...(maxPanelHeight != null ? { maxHeight: maxPanelHeight + 'px' } : {}),
}}>
${isOn && !noHide && html`
<span
class="cs-option"
role="option"
onClick=${() => { onChange('off'); setOpen(false); }}
>${hidingLabel || ('Hide ' + hideLabel)}</span>
<div class="cs-divider"></div>
`}
${options.map(opt => opt.divider
? html`<div key=${opt.value} class="cs-divider"></div>`
: html`<span
key=${opt.value}
class=${`cs-option${opt.value === value && isOn ? ' selected' : ''}${opt.disabled ? ' disabled' : ''}`}
role="option"
onClick=${() => {
if (opt.disabled) return;
onChange(opt.value);
setOpen(false);
}}
>${opt.label}</span>`
)}
</div>
`}
</span>`;
}
// -------------------------------------------------------------------
// VENTPILL - the checkbox pill fused to the right of a CustomSelect.
// -------------------------------------------------------------------
// props:
// checked - bool
// onChange - fn()
// label - string
// title - tooltip string
// -------------------------------------------------------------------
export function VentPill({ checked, onChange, label, title, grpClass }) {
return html`
<label class=${[`cs-vent`, grpClass || '', checked ? 'on' : ''].filter(Boolean).join(' ')} title=${title}>
<input
type="checkbox"
checked=${checked}
onChange=${onChange}
style=${{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
/>
<span class=${`cs-checkbox${checked ? ' checked' : ''}`}></span>
${label}
</label>`;
}
// -------------------------------------------------------------------
// ROWSTEPPER - "Rows [-] 3h [+]"
// -------------------------------------------------------------------
// Sets how many hours each forecast row covers. NOT a lookahead: the
// value picks the clock-aligned bucket size used by aggregateRows()
// (1h = every hour, 4h = quarter-day blocks).
//
// "-" steps toward 1h (finer, more rows), "+" toward 4h (coarser).
// Direction follows the readout, so + always raises the number shown.
//
// props:
// value - 1..4
// onChange - fn(n)
// showLabel - draw the caption (off inside the table's Rows header,
// where the column title already supplies the context)
// label - caption text: "Rows" by default, "Hours" in quick view,
// where each card is an hour rather than a table row
// min/max - bounds, so 6h/12h can be added without a redesign
// -------------------------------------------------------------------
export function RowStepper({ value, onChange, showLabel = true, label = 'Rows', min = 1, max = 4 }) {
const v = Math.min(max, Math.max(min, Number(value) || min));
const step = (n) => { const next = Math.min(max, Math.max(min, n)); if (next !== v) onChange(next); };
return html`
<span class="row-stepper" role="group" aria-label="Row interval">
${showLabel && html`<span class="row-stepper-label">${label}</span>`}
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}></button>
<span class="row-stepper-value" aria-live="polite"
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
<button type="button" class="row-stepper-btn" disabled=${v >= max}
title="Coarser rows" aria-label="Coarser rows - more hours per row"
onClick=${() => step(v + 1)}>+</button>
</span>`;
}
// -------------------------------------------------------------------
// SKYSCOPE - the little porthole circle next to each hour.
// -------------------------------------------------------------------
// What gets drawn (back to front):
// 1. Sky disk - colour from skyFillForElev() above
// 2. Horizon line - faint dashed line at the middle
// 3. Sun OR moon - sun positioned vertically by elevation;
// moon shows real phase (synodic period)
// 4. Brass ring - the telescope/scope edge (#c8922a)
// 5. Lens highlight - subtle inner glint
//
// Tweakable bits inside this function:
// - size - pass a different size= when calling for bigger/smaller
// - innerR - how thick the brass ring looks
// - sunR - the sun's drawn radius
// - the stroke colour #c8922a is the brass - change for a different metal
// -------------------------------------------------------------------
export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const r = size / 2;
const innerR = r - 1.2;
// Rising if local hour < 12 (dt.getUTCHours() == local hour after timezone fix)
const localHour = dt ? dt.getUTCHours() : 12;
const isRising = localHour < 12;
const skyGrad = skyGradientForElev(elev, isRising);
const isDay = elev > 0; // sun only drawn when actually above horizon
const isTwil = elev > -8 && !isDay; // moon shown in twilight too
const isNight = elev <= -8;
// Sun position - clamped so it never goes below the horizon line (y = r)
const elevClamped = Math.max(0, Math.min(90, elev));
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
const sunR = Math.max(2.2, innerR * 0.32);
// Radial glow: strength driven by glob (W/m-). Uses a radialGradient
// so it's intense at the sun centre and fades smoothly to transparent.
const radFrac = Math.min(1, (glob || 0) / 900); // 0 (dark) - 1 (blazing)
const horizFrac = Math.max(0, 1 - elev / 20); // extra warmth near horizon
const glowR = sunR + 4 + radFrac * 6; // total glow radius
const glowPeak = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
const phase = moonPhaseFraction(dt);
const moonR = innerR * 0.55;
const shadowOffset = (1 - Math.abs(2 * phase - 1)) * 2 * moonR;
const shadowCx = r + (phase < 0.5 ? -shadowOffset : shadowOffset);
const grass = grassFillForElev(elev);
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}`;
const clipId = `clip-${uid}`;
const skyId = `sky-${uid}`;
const grassId = `grass-${uid}`;
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
<defs>
<clipPath id=${clipId}>
<circle cx=${r} cy=${r} r=${innerR} />
</clipPath>
<!-- Sky gradient — top of disk to bottom (zenith → horizon) -->
<linearGradient id=${skyId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${skyGrad.top} />
<stop offset="100%" stop-color=${skyGrad.bot} />
</linearGradient>
<!-- Grass gradient — horizon to ground -->
<linearGradient id=${grassId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${grass.top} />
<stop offset="100%" stop-color=${grass.bot} />
</linearGradient>
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
<radialGradient id=${`glow-${uid}`} gradientUnits="userSpaceOnUse"
cx=${r} cy=${sunY} r=${glowR}>
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
<stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
<stop offset="70%" stop-color="#ffe060" stop-opacity=${glowPeak * 0.5} />
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: lit area = white, shadow = black punched out -->
<mask id=${`moon-mask-${uid}`}>
<circle cx=${r} cy=${r} r=${moonR} fill="white" />
<circle cx=${shadowCx} cy=${r} r=${moonR} fill="black" />
</mask>
<!-- Moon texture pattern - sized to fit the moon disk -->
<pattern id=${`moon-tex-${uid}`} x=${r - moonR - 2} y=${r - moonR - 2}
width=${moonR * 2 + 4} height=${moonR * 2 + 4} patternUnits="userSpaceOnUse">
<image href=${MOON_TEX} x="0" y="0" width=${moonR * 2 + 4} height=${moonR * 2 + 4} />
</pattern>
</defs>
<!-- Sky disk -->
<circle cx=${r} cy=${r} r=${innerR} fill=${`url(#${skyId})`} />
<!-- Ground half (lower semicircle) -->
<path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
fill=${`url(#${grassId})`}
clip-path=${`url(#${clipId})`} />
${isDay && html`
<g clip-path=${`url(#${clipId})`}>
<!-- Glow + sun disk as one seamless radial gradient circle -->
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`} />
</g>`}
${(isTwil || isNight) && html`
<g clip-path=${`url(#${clipId})`}>
<!-- Lit crescent via mask -->
<circle cx=${r} cy=${r} r=${moonR}
fill="#e7e3d4" mask=${`url(#moon-mask-${uid})`} />
<!-- Texture overlay on lit face -->
<circle cx=${r} cy=${r} r=${moonR}
fill=${`url(#moon-tex-${uid})`}
mask=${`url(#moon-mask-${uid})`}
opacity="0.55" />
<!-- Dim unlit face so the full disk outline remains visible -->
<circle cx=${r} cy=${r} r=${moonR}
fill="rgba(200,192,230,0.08)" stroke="none" />
</g>`}
<!-- Brass scope ring -->
<circle cx=${r} cy=${r} r=${innerR} fill="none" stroke="#c8922a" stroke-width="0.9" />
<!-- Inner highlight glint -->
<circle cx=${r} cy=${r} r=${innerR - 0.5} fill="none"
stroke="rgba(255,255,255,0.22)" stroke-width="0.4" />
</svg>`;
}
// -------------------------------------------------------------------
// WINDVANE - clean compass arrow on transparent background.
// -------------------------------------------------------------------
// Traditional weather-vane behaviour: the ARROWHEAD points INTO the
// wind (toward the source). Wind from the north - head points north.
//
// props:
// bearing - degrees, 0 = wind FROM north
// size - pixel diameter (default 30 to match SkyScope)
//
// Tweakable bits:
// - arrowColor / nMarkerColor - line/fill colours
// - Tiny N letter sits just above the arrow tail for orientation
// -------------------------------------------------------------------
export function WindVane({ bearing, size = 30 }) {
if (bearing == null || isNaN(bearing)) {
return html`<span style=${{ opacity: 0.5, fontSize: '11px' }}>—</span>`;
}
const r = size / 2;
// The shaft is drawn pointing DOWN by default (head at the bottom).
// To place the head at the bearing direction, rotate by bearing + 180.
const rot = (bearing + 180) % 360;
const arrowColor = '#2a1a08';
const headColor = '#c44a3a';
const nColor = '#9a7d5a';
// Geometry of the single-ended arrow (drawn pointing DOWN by default).
// After rotation by (bearing + 180), the head lands on the bearing
// direction - i.e. the side the wind is coming FROM.
const tipY = r * 0.15; // arrowhead tip
const headBase = r * 0.58; // bottom of the triangle head
const headW = r * 0.42;
const tailEndY = r * 1.78; // shaft's tail end
const tailDotR = Math.max(1.0, size * 0.06);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<!-- Static N marker — small letter above the centre, doesn't rotate -->
<text x=${r} y=${size * 0.18} text-anchor="middle"
font-family="Manrope, sans-serif"
font-size=${size * 0.26} font-weight="700"
fill=${nColor} opacity="0.55">N</text>
<!-- Rotating arrow. Shaft + ONE arrowhead. A small round nub on
the tail end keeps the orientation unambiguous without
looking like a second arrowhead. -->
<g transform=${`rotate(${rot} ${r} ${r})`}>
<!-- shaft -->
<line x1=${r} y1=${headBase} x2=${r} y2=${tailEndY}
stroke=${arrowColor} stroke-width=${Math.max(1.4, size*0.07)} stroke-linecap="round" />
<!-- single arrowhead at the upwind end (head into the wind) -->
<polygon points=${`${r - headW},${headBase} ${r + headW},${headBase} ${r},${tipY}`}
fill=${headColor} stroke=${arrowColor} stroke-width="0.6" stroke-linejoin="round" />
<!-- small dot at the downwind end of the shaft, so orientation
is unambiguous (no chance of reading the tail as a head) -->
<circle cx=${r} cy=${tailEndY} r=${tailDotR} fill=${arrowColor} />
</g>
</svg>`;
}
// -------------------------------------------------------------------
// CLOUDICON - Brass Line cloud / haze / overcast glyphs.
// The time-cell SkyScope owns full day/night imagery; this stays a
// simple glyph. Exception: 'wispy'/'scattered' in daylight draw a
// small sun peeking from behind the cloud, so a lightly-clouded day
// still reads as "mostly sunny" rather than looking fully overcast.
// -------------------------------------------------------------------
// category: 'clear' | 'wispy' | 'scattered' | 'overcast'
// elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase
// -------------------------------------------------------------------
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), filled = false, showSun = true }) {
const r = size / 2;
const brass = '#c8922a';
const ink = '#2a1a08';
const muted = '#b09870';
const sw = Math.max(1.25, size * 0.058);
const cap = 'round';
const join = 'round';
const cloudPath = (dx = 0, dy = 0, scale = 1) => {
const x = (n) => r + n * r * scale + dx;
const y = (n) => r + n * r * scale + dy;
return `M ${x(-0.76)} ${y(0.25)}
H ${x(0.56)}
C ${x(0.82)} ${y(0.25)}, ${x(0.86)} ${y(-0.08)}, ${x(0.58)} ${y(-0.10)}
C ${x(0.48)} ${y(-0.44)}, ${x(0.08)} ${y(-0.52)}, ${x(-0.10)} ${y(-0.22)}
C ${x(-0.42)} ${y(-0.33)}, ${x(-0.76)} ${y(-0.08)}, ${x(-0.76)} ${y(0.25)} Z`;
};
const line = (x1, y1, x2, y2, color = muted, width = sw, opacity = 1) => html`
<line x1=${x1} y1=${y1} x2=${x2} y2=${y2}
stroke=${color} stroke-width=${width} stroke-linecap=${cap} opacity=${opacity} />`;
const mist = (y0, color = muted) => html`
<g>
${line(size * 0.18, y0, size * 0.80, y0, color, sw * 0.9, 0.88)}
${line(size * 0.30, y0 + size * 0.18, size * 0.92, y0 + size * 0.18, ink, sw * 0.82, 0.72)}
</g>`;
// Sun shape - circle + 8 rays, centred at (cx, cy)
const sun = (cx, cy, cr) => {
const rays = Array.from({ length: 8 }, (_, k) => {
const angle = (k * Math.PI) / 4;
const r1 = cr * 1.55;
const r2 = cr * 2.1;
return html`<line key=${k}
x1=${cx + Math.cos(angle) * r1} y1=${cy + Math.sin(angle) * r1}
x2=${cx + Math.cos(angle) * r2} y2=${cy + Math.sin(angle) * r2}
stroke=${brass} stroke-width=${sw * 0.85} stroke-linecap="round" opacity="0.9" />`;
});
return html`<g>
${rays}
<circle cx=${cx} cy=${cy} r=${cr} fill="none" stroke=${brass} stroke-width=${sw} opacity="0.95" />
</g>`;
};
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && elev >= 0 && showSun && html`
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'clear' && elev < 0 && html`
<path d=${`M ${size*0.45} ${size*0.15}
A ${size*0.20} ${size*0.20} 0 1 1 ${size*0.45} ${size*0.55}
A ${size*0.235} ${size*0.235} 0 1 0 ${size*0.45} ${size*0.15} Z`}
fill=${brass} opacity="0.85" />
<circle cx=${size*0.82} cy=${size*0.22} r=${size*0.030} fill=${brass} opacity="0.75" />
<circle cx=${size*0.20} cy=${size*0.28} r=${size*0.022} fill=${brass} opacity="0.65" />
<circle cx=${size*0.75} cy=${size*0.60} r=${size*0.025} fill=${brass} opacity="0.70" />
<circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" />
<circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`}
${category === 'wispy' && elev >= 0 && html`
${showSun && sun(size * 0.66, size * 0.30, size * 0.13)}
${line(size * 0.14, size * 0.58, size * 0.64, size * 0.58, brass, sw * 0.85, 0.85)}
${line(size * 0.24, size * 0.74, size * 0.78, size * 0.74, ink, sw * 0.78, 0.68)}`}
${category === 'wispy' && elev < 0 && html`
${mist(size * 0.18, brass)}
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
${category === 'scattered' && elev >= 0 && html`
${showSun && sun(size * 0.72, size * 0.24, size * 0.11)}
<path d=${cloudPath(-size * 0.20, -size * 0.32, 0.36)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.65} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
<path d=${cloudPath(size * 0.18, -size * 0.38, 0.30)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.6} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
<path d=${cloudPath(0, -size * 0.20, 0.28)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.55} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />`}
${category === 'scattered' && elev < 0 && html`
<path d=${cloudPath(0, -size * 0.2425, 0.82)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
${!filled && line(size * 0.20, size * 0.58, size * 0.48, size * 0.58, brass, sw * 0.75, 0.78)}`}
${category === 'overcast' && html`
<path d=${cloudPath(-size * 0.02, -size * 0.2275, 0.70)} fill=${filled ? 'rgba(220,230,240,0.72)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.9} stroke-linecap=${cap} stroke-linejoin=${join} opacity="0.82" />
<path d=${cloudPath(0, -size * 0.25, 0.88)} fill=${filled ? 'rgba(255,255,255,0.92)' : 'none'} stroke=${filled ? 'none' : ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />`}
</svg>`;
}
// -------------------------------------------------------------------
// SCOPERETICLE - the big circular UTCI dial in the page header.
// -------------------------------------------------------------------
// This is the one with tick marks, the swept colour band, and the
// needle pointing at the current felt-temperature.
//
// Tweakable bits:
// - R = 86 outer ring radius (changes overall size)
// - cx, cy = 100 centre point (leave alone unless you also
// change the viewBox="0 0 200 200" below)
// - { length: 36 } number of tick marks (1 every 10-)
// - stressBands[] the colour ramp around the rim (matches UTCI)
// - (value + 10) / 60 maps UTCI -10..50 onto the 270- sweep -
// widen the dial range by changing those numbers
// -------------------------------------------------------------------
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0, activeEvent = null, utciEnvShort = null,
cc = null, ccLow = null, ccMid = null, ccHigh = null, precip = 0, snow = 0, visKm = null,
env = 'open', wind = 0, gust = 0, wd = null }) {
// -- Day/night scene behind the readout -----------------------------
// The whole back of the lens is a SkyScope-style scene: sky on top,
// grass on the bottom, sun positioned by elevation (or moon at night
// with its phase shadow). Mirrors the little hourly SkyScopes.
const cx = 100, cy = 100, R = 86;
const localHour = dt ? dt.getUTCHours() : 12;
const isRising = localHour < 12;
const isDay = elev > 0; // sun only above the actual horizon
const isTwil = elev > -8 && !isDay;
const skyGrad = skyGradientForElev(elev, isRising);
const skyHorizon = skyGrad.bot;
// --- Live weather layer ------------------------------------------------
// Continuous, data-driven sky/cloud/precip state (not event-triggered).
const ccTotal = cc != null ? cc : 0;
const cloudF = Math.min(1, ccTotal / 100);
const rainRate = precip != null ? precip : 0; // mm/h
const snowRate = snow != null ? snow : 0; // cm/h
const windSpeed = wind != null ? wind : 0; // m/s
const gustSpeed = (gust != null && gust > windSpeed) ? gust : windSpeed;
// Horizontal drift unit from wind direction (meteorological "from" degrees):
// the blowing-to east-west component is -sin(wd); + = rightward. Floored so
// there's always visible cross-motion even for near-N/S winds.
let windH = wd != null ? -Math.sin(wd * Math.PI / 180) : 0.4;
if (Math.abs(windH) < 0.35) windH = (windH < 0 ? -1 : 1) * 0.35;
const wet = rainRate > 0 || snowRate > 0;
const heavy = rainRate >= 4 || snowRate >= 0.5;
const stormy = heavy || (activeEvent && activeEvent.id === 'storm');
// Overcast greys the sky; precipitation/storm darkens it further. Night
// skies go to a cold near-black rather than daytime flat grey.
const greyTarget = isDay
? (stormy ? '#6f767d' : '#a6acb2')
: (stormy ? '#161a23' : '#2b303b');
let tintAmt = cloudF * 0.72;
if (wet) tintAmt = Math.min(0.88, tintAmt + 0.14);
if (heavy) tintAmt = Math.min(0.93, tintAmt + 0.12);
const skyTopTint = blendHex(skyGrad.top, isDay ? greyTarget : blendHex(greyTarget, '#0e1018', 0.4), tintAmt * 0.8);
const skyBotTint = blendHex(skyGrad.bot, greyTarget, tintAmt);
// Drifting cloud sprites, one strip per coverage layer. Each strip holds a
// few sprites duplicated +200px so the CSS drift wraps seamlessly.
//
// These are rendered as real (keyed) Preact nodes — NOT injected HTML — so
// the <g> persists across re-renders and the CSS drift animation keeps
// running instead of restarting (which looked like the clouds shaking).
const cloudDim = isDay ? 1 : 0.55; // moonlit clouds read dimmer at night
function buildCloudLayer(cov, yBand, scale, durSec, seed, key) {
// Always render the maximum 4 sprites so existing <image> elements persist
// in the DOM and their opacity can CSS-transition instead of blinking in/out.
const maxCount = 4;
const f = cov != null ? Math.min(1, cov / 100) : 0;
const activeCount = (cov != null && cov >= 8) ? Math.round(1 + f * 3) : 0;
const baseOp = Math.min(0.9, (0.22 + f * 0.6) * cloudDim);
const w = 92 * scale, hh = 48 * scale;
const imgs = [];
for (let k = 0; k < maxCount; k++) {
const px = (seed * 47 + k * (200 / maxCount)) % 200;
const py = yBand + (((seed + k * 7) % 12) - 6);
const spriteOp = k < activeCount ? baseOp : 0;
[px, px + 200].forEach((X, j) => {
imgs.push(html`<image key=${k + '-' + j} href=${CLOUD_IMG}
x=${(X - w / 2).toFixed(1)} y=${(py - hh / 2).toFixed(1)}
width=${w.toFixed(1)} height=${hh.toFixed(1)} opacity=${spriteOp.toFixed(2)}
style="transition:opacity 2s ease"
preserveAspectRatio="xMidYMid meet" />`);
});
}
return html`<g key=${key} class="scope-cloud-layer"
style=${`--drift:-200px;animation-duration:${durSec}s`}>${imgs}</g>`;
}
const cloudLayers = [
buildCloudLayer(ccHigh, 44, 0.7, 70, 3, 'ch'),
buildCloudLayer(ccMid, 64, 0.9, 52, 7, 'cm'),
buildCloudLayer(ccLow != null ? ccLow : ccTotal, 84, 1.15, 40, 11, 'cl'),
];
// Fog/haze wash when visibility drops (km). Drawn under the dial graphics.
const fogAlpha = (visKm != null && visKm < 8) ? Math.min(0.5, (8 - visKm) / 8 * 0.5) : 0;
const fogColor = isDay ? '225,228,232' : '120,128,140';
// --- Ground scene -----------------------------------------------------
// Per-environment ground: a multi-stop gradient (some split into bands like
// beach sea/sand or a river's bank/water/bank) plus objects scattered with
// ground-plane perspective (lower = nearer = bigger; far = small + hazy).
// Every surface AND object flows through one colour-cycle model: darken by
// elevation (day → night), tint toward the live horizon sky (golden hour /
// overcast / storm), and lightly whiten land grounds when it snows. Drawn
// UNDER the dial, with deeper objects pushed off-centre and a soft scrim
// behind the readout to keep the numbers legible.
const groundR = 95; // = lens radius
const groundOpacity = +(0.96 * (1 - Math.min(0.7, fogAlpha * 1.2))).toFixed(2);
const groundSnowy = snowRate > 0;
// Per-mount random seed: each page load arranges the objects differently,
// but it's fixed for the session so re-renders / playback never re-shuffle.
const layoutSeedRef = useRef((Math.random() * 1e9) | 0);
const layoutSeed = layoutSeedRef.current;
const spec = GROUND_SPEC[env] || GROUND_SPEC.open;
// Day → night darkening amount from solar elevation.
const nightAmt = elev >= 6 ? 0 : elev <= -12 ? 0.85 : (6 - elev) / 18 * 0.85;
// Runs one daytime base colour through the shared cycle.
function groundTone(dayHex, allowSnow) {
let c = blendHex(dayHex, spec.night, nightAmt); // day → night
c = blendHex(c, skyBotTint, 0.16); // ambient sky tint
c = blendHex(c, greyTarget, tintAmt * 0.32); // heavier weather grey
if (allowSnow && spec.snowGround && groundSnowy)
c = blendHex(c, '#eef2f6', Math.min(0.5, snowRate * 0.5));
return c;
}
const groundStops = spec.grad.map(([off, hex]) => ({ off: +(off * 100).toFixed(1), col: groundTone(hex, true) }));
// Depth-shaded silhouette endpoints, tied to the live sky tone but kept
// dark enough to read as silhouettes against bright ground/sky (near = very
// dark, far = hazier but still clearly visible).
const nearTone = blendHex(skyBotTint, '#0a0e16', 0.68); // close & dark
const farTone = blendHex(skyBotTint, '#0a0e16', 0.42); // distant & hazy
// Colours a scattered object by its material, then applies the cycle:
// darken at night, tint toward the sky (ambient + distance haze), grey in
// bad weather. Distant objects (low d) pick up more sky → recede/haze.
function objTone(dayHex, d) {
let c = blendHex(dayHex, '#0a0e16', nightAmt * 0.82); // darken toward night
c = blendHex(c, skyBotTint, 0.12 + (1 - d) * 0.32); // ambient + distance haze
c = blendHex(c, greyTarget, tintAmt * 0.3); // weather grey
return c;
}
const snowTone = blendHex(skyBotTint, '#ffffff', isDay ? 0.6 : isTwil ? 0.38 : 0.20);
// Readout scrim colour: light by day (dark text), dark by night (light text).
const scrimColor = isDay ? '#f1e8d0' : '#0b0916';
function buildGround() {
const cyB = 100;
const out = [];
let i = 0;
const P = (d, f, o) => out.push(html`<path key=${'g' + (i++)} d=${d} fill=${f} opacity=${o} />`);
const C = (x, y, r, f, o) => out.push(html`<circle key=${'g' + (i++)} cx=${x} cy=${y} r=${r} fill=${f} opacity=${o} />`);
// Shapes grow upward from their ground point (x, by) to height s.
const pine = (x, by, s, f, o) => {
const w = s * 0.4;
P(`M ${x} ${by - s} L ${x + w * 0.5} ${by - s * 0.5} L ${x + w * 0.22} ${by - s * 0.5} L ${x + w} ${by} L ${x - w} ${by} L ${x - w * 0.22} ${by - s * 0.5} L ${x - w * 0.5} ${by - s * 0.5} Z`, f, o);
if (groundSnowy) P(`M ${x} ${by - s} L ${x - w * 0.26} ${by - s * 0.72} L ${x + w * 0.26} ${by - s * 0.72} Z`, snowTone, o);
};
const oak = (x, by, s, f, o) => { // broad rounded crown
const tw = s * 0.08;
P(`M ${x - tw} ${by} L ${x - tw * 0.7} ${by - s * 0.5} L ${x + tw * 0.7} ${by - s * 0.5} L ${x + tw} ${by} Z`, f, o);
C(x, by - s * 0.66, s * 0.34, f, o);
C(x - s * 0.27, by - s * 0.54, s * 0.26, f, o);
C(x + s * 0.27, by - s * 0.54, s * 0.26, f, o);
C(x - s * 0.12, by - s * 0.82, s * 0.24, f, o);
C(x + s * 0.15, by - s * 0.8, s * 0.22, f, o);
};
const bush = (x, by, s, f, o) => {
C(x - s * 0.3, by - s * 0.24, s * 0.3, f, o);
C(x + s * 0.28, by - s * 0.22, s * 0.26, f, o);
C(x, by - s * 0.4, s * 0.36, f, o);
};
const hedge = (x, by, s, f, o) => { // low wide rounded row
const w = s * 1.3, h = s * 0.6;
P(`M ${x - w} ${by} L ${x - w} ${by - h * 0.7} L ${x + w} ${by - h * 0.7} L ${x + w} ${by} Z`, f, o);
C(x - w * 0.6, by - h * 0.7, h * 0.55, f, o);
C(x, by - h * 0.85, h * 0.6, f, o);
C(x + w * 0.6, by - h * 0.7, h * 0.55, f, o);
};
const building = (x, by, s, f, o, wf) => {
const w = s * (wf || 0.5);
P(`M ${x - w / 2} ${by} L ${x - w / 2} ${by - s} L ${x + w / 2} ${by - s} L ${x + w / 2} ${by} Z`, f, o);
};
const cactus = (x, by, s, f, o) => {
const t = s * 0.11;
P(`M ${x - t} ${by} L ${x - t} ${by - s} Q ${x - t} ${by - s - t} ${x} ${by - s - t} Q ${x + t} ${by - s - t} ${x + t} ${by - s} L ${x + t} ${by} Z`, f, o);
P(`M ${x - t} ${by - s * 0.6} l ${-s * 0.22} 0 l 0 ${-s * 0.3} l ${s * 0.1} 0 l 0 ${s * 0.2} l ${s * 0.12} 0 Z`, f, o);
P(`M ${x + t} ${by - s * 0.45} l ${s * 0.22} 0 l 0 ${-s * 0.3} l ${-s * 0.1} 0 l 0 ${s * 0.2} l ${-s * 0.12} 0 Z`, f, o);
};
const palm = (x, by, s, f, o) => {
P(`M ${x - s * 0.05} ${by} Q ${x - s * 0.18} ${by - s * 0.6} ${x - s * 0.11} ${by - s} L ${x} ${by - s} Q ${x - s * 0.07} ${by - s * 0.6} ${x + s * 0.05} ${by} Z`, f, o);
const tx = x - s * 0.11, ty = by - s, fr = s * 0.45;
P(`M ${tx} ${ty} L ${tx - fr} ${ty - fr * 0.28} L ${tx - fr * 0.7} ${ty + fr * 0.16} Z`, f, o);
P(`M ${tx} ${ty} L ${tx + fr} ${ty - fr * 0.28} L ${tx + fr * 0.7} ${ty + fr * 0.16} Z`, f, o);
P(`M ${tx} ${ty} L ${tx - fr * 0.3} ${ty - fr} L ${tx + fr * 0.3} ${ty - fr * 0.9} Z`, f, o);
P(`M ${tx} ${ty} L ${tx + fr * 0.62} ${ty - fr * 0.8} L ${tx + fr * 0.86} ${ty - fr * 0.42} Z`, f, o);
};
const parasol = (x, by, s, f, o) => {
P(`M ${x - s * 0.04} ${by} L ${x - s * 0.04} ${by - s} L ${x + s * 0.04} ${by - s} L ${x + s * 0.04} ${by} Z`, f, o);
P(`M ${x - s * 0.5} ${by - s} Q ${x} ${by - s - s * 0.42} ${x + s * 0.5} ${by - s} Z`, f, o);
};
const reed = (x, by, s, f, o) => {
for (let k = -1; k <= 1; k++) {
const rx = x + k * s * 0.22, rh = s - Math.abs(k) * s * 0.28;
P(`M ${rx - s * 0.04} ${by} L ${rx - s * 0.04} ${by - rh} L ${rx + s * 0.04} ${by - rh} L ${rx + s * 0.04} ${by} Z`, f, o);
C(rx, by - rh - s * 0.08, s * 0.09, f, o);
}
};
const boat = (x, by, s, f, o) => {
P(`M ${x - s * 0.45} ${by - s * 0.12} L ${x + s * 0.45} ${by - s * 0.12} L ${x + s * 0.3} ${by + s * 0.05} L ${x - s * 0.3} ${by + s * 0.05} Z`, f, o);
P(`M ${x} ${by - s * 0.12} L ${x} ${by - s} L ${x + s * 0.4} ${by - s * 0.22} Z`, f, o);
P(`M ${x} ${by - s * 0.12} L ${x - s * 0.05} ${by - s * 0.85} L ${x - s * 0.36} ${by - s * 0.18} Z`, f, o);
};
const DRAW = { pine, oak, bush, hedge, building, cactus, palm, parasol, reed, boat };
// Alpine: a distant range of irregular, jagged peaks (not pyramids) —
// off-centre summits with sub-shoulders, hazier the further back.
if (spec.obj.mountains) {
const capTone = blendHex(skyBotTint, '#ffffff', isDay ? 0.72 : isTwil ? 0.46 : 0.24);
const mtn = (x, w, h, seed, d) => {
const r = seededRng(seed);
const tone = objTone('#6a6258', d); // rocky; far = hazier
// Summit can shift a little, but is clamped to stay between its
// neighbour points so the ridge never backtracks into a hook.
const leftN = [x - w * 0.12, cyB - h * (0.72 + 0.08 * r())];
const rightN = [x + w * 0.16, cyB - h * (0.62 + 0.16 * r())];
const peakX = Math.max(leftN[0] + w * 0.02, Math.min(rightN[0] - w * 0.02, x + (r() - 0.5) * w * 0.18));
const peak = [peakX, cyB - h];
const pts = [
[x - w, cyB],
[x - w * 0.58, cyB - h * (0.30 + 0.22 * r())],
[x - w * 0.30, cyB - h * (0.50 + 0.14 * r())],
leftN, peak, rightN,
[x + w * 0.42, cyB - h * (0.36 + 0.18 * r())],
[x + w * 0.72, cyB - h * (0.16 + 0.12 * r())],
[x + w, cyB],
];
P('M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ') + ' Z', tone, groundOpacity);
// Snow cap — always white, covering the top ~half of the peak. Traced
// down the real ridge edges (peak → shoulder → mid-slope) so it can't
// spill outside the silhouette, with a jagged snow line across.
const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const leftDeep = pts[2], rightDeep = pts[6];
const lEdge = lerp(leftN, leftDeep, 0.55); // down the left slope
const rEdge = lerp(rightN, rightDeep, 0.5); // down the right slope
const baseY = (lEdge[1] + rEdge[1]) / 2;
const j1 = [lerp(rEdge, lEdge, 0.34)[0], baseY - h * 0.05];
const j2 = [lerp(rEdge, lEdge, 0.66)[0], baseY + h * 0.035];
const fmt = p => p[0].toFixed(1) + ' ' + p[1].toFixed(1);
P(`M ${fmt(peak)} L ${fmt(rightN)} L ${fmt(rEdge)} L ${fmt(j1)} L ${fmt(j2)} L ${fmt(lEdge)} L ${fmt(leftN)} Z`, capTone, groundOpacity);
};
mtn(cx - 30, 52, 30, spec.obj.seed + layoutSeed + 1, 0.05);
mtn(cx + 26, 46, 37, spec.obj.seed + layoutSeed + 2, 0.16);
mtn(cx + 64, 34, 22, spec.obj.seed + layoutSeed + 3, 0.10);
}
// City: 34 receding skyline rows — back rows higher, hazier, lighter;
// front rows lower, darker, with occasional landmarks + lit windows.
if (spec.obj.skyline) {
const rows = 4;
for (let L = 0; L < rows; L++) {
const depth = L / (rows - 1); // 0 back .. 1 front
const by = cyB + (0.16 + depth * 0.5) * 86;
const fill = blendHex(farTone, nearTone, 0.2 + depth * 0.72);
const op = +(groundOpacity * (0.7 + depth * 0.3)).toFixed(2);
const rr = seededRng(spec.obj.seed + layoutSeed + L * 101);
let x = 4;
while (x < 196) {
const w = (5 + rr() * 9) * (0.6 + depth * 0.7);
const cDist = Math.abs((x + w / 2) - cx) / 100; // 0 centre .. ~1 edge
let h = (5 + rr() * 20) * (0.5 + depth) * (0.45 + 0.55 * cDist);
h = Math.min(h, by - 104);
if (h > 2) {
building(x + w / 2, by, h, fill, op, w / h);
const top = by - h, cxb = x + w / 2;
if (depth > 0.55 && rr() < 0.22) { // landmark roof
const r = rr(), spike = 5 * (0.6 + depth);
if (r < 0.4) P(`M ${cxb - 0.6} ${top} L ${cxb - 0.6} ${top - spike} L ${cxb + 0.6} ${top - spike} L ${cxb + 0.6} ${top} Z`, fill, op);
else if (r < 0.72) P(`M ${x + w * 0.3} ${top} Q ${cxb} ${top - w * 0.55} ${x + w * 0.7} ${top} Z`, fill, op);
else P(`M ${x + w * 0.28} ${top} L ${x + w * 0.28} ${top - w * 0.4} L ${x + w * 0.72} ${top - w * 0.4} L ${x + w * 0.72} ${top} Z`, fill, op);
}
if (depth > 0.78 && h > 14 && w > 8) { // sparse lit windows
const wf2 = blendHex(fill, skyBotTint, 0.3);
for (let sx = x + 2; sx < x + w - 1.5; sx += 2.6)
P(`M ${sx} ${by - 2} L ${sx} ${top + 2} L ${sx + 0.8} ${top + 2} L ${sx + 0.8} ${by - 2} Z`, wf2, op * 0.85);
}
}
x += w + rr() * 2;
}
}
}
// Scattered objects with ground-plane perspective, constrained to the
// env's allowed depth bands (e.g. beach sand, river banks).
if (spec.obj.types) {
const cfg = spec.obj;
const rnd = seededRng(cfg.seed + layoutSeed);
const bands = cfg.bands || [[0.12, 0.92]];
const objs = [];
for (let k = 0; k < cfg.n; k++) {
const band = bands[k % bands.length];
// Stratify horizontally so objects spread evenly across the width
// (with jitter) instead of clustering; deeper objects sit lower.
// randomX bypasses stratification for fully organic placement (e.g. forest).
const slot = (k + 0.5) / cfg.n; // 0..1 even
const jitter = (rnd() - 0.5) * (1.4 / cfg.n);
const xf = cfg.randomX ? (rnd() * 2 - 1) : Math.max(-1, Math.min(1, (slot * 2 - 1) + jitter));
objs.push({
d: band[0] + rnd() * (band[1] - band[0]),
xf,
type: cfg.types[Math.floor(rnd() * cfg.types.length)],
wf: 0.4 + rnd() * 0.5,
});
}
objs.sort((a, b) => a.d - b.d); // far → near
for (const o of objs) {
const by = cyB + o.d * 86; // lower = nearer
const hw = Math.sqrt(Math.max(0, groundR * groundR - (by - cyB) * (by - cyB)));
// Mild nudge off the readout column (the scrim handles legibility).
let xf = o.xf;
if (by > 118) { const av = 0.18 + o.d * 0.18; xf = (xf < 0 ? -1 : 1) * (av + (1 - av) * Math.abs(xf)); }
const x = cx + xf * hw * 0.9;
const s = cfg.base * (0.4 + o.d * (cfg.sizeMul || 1.25)); // nearer = bigger
const fill = objTone(MATERIAL[o.type] || '#4f7d39', o.d); // material colour + cycle
const op = +(groundOpacity * (1 - Math.min(0.6, fogAlpha * (1.5 - o.d)))).toFixed(2);
(DRAW[o.type] || oak)(x, by, s, fill, op, o.wf);
}
}
return out;
}
const groundShapes = buildGround();
const phase = moonPhaseFraction(dt);
const lensR = 95;
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
const sunDrawR = 14;
// Radial glow for the big dial - sun is always at least faintly glowing
// when above the horizon; scales up with radiation strength.
const radFrac = Math.min(1, (glob || 0) / 900);
const horizFrac = Math.max(0, 1 - elev / 20);
const sunGlowCol = isRising ? '#ffb040' : '#ffc060';
const sunGlowR = sunDrawR + 20 + radFrac * 30; // total glow radius
const sunGlowPeak = 0.30 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
const moonDrawR = 15;
const moonY = cy - 45;
const shadowOffset = (1 - Math.abs(2 * phase - 1)) * 2 * moonDrawR;
const moonShadowCx = cx + (phase < 0.5 ? -shadowOffset : shadowOffset);
// Readout text colours flip with day/night for legibility against the sky.
const readoutColor = isDay ? '#1e1208' : '#f5edd6';
const readoutMutedColor = isDay ? '#9a7d5a' : '#d4c5a8';
const ticks = Array.from({ length: 36 }, (_, i) => {
const deg = i * 10 - 90;
const rad = (deg * Math.PI) / 180;
const major = i % 9 === 0;
const medium = i % 3 === 0;
const r2 = major ? R - 14 : medium ? R - 8 : R - 4;
return {
x1: cx + R * Math.cos(rad), y1: cy + R * Math.sin(rad),
x2: cx + r2 * Math.cos(rad), y2: cy + r2 * Math.sin(rad),
major, medium,
};
});
function fracToXY(frac, r) {
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
// Gradient colour stops: frac 0 = 10 °C, frac 1 = 50 °C
const ARC_GRAD = [
[0, [106, 169, 225]],
[0.167, [147, 204, 217]],
[0.317, [146, 212, 163]],
[0.467, [218, 225, 95]],
[0.600, [248, 161, 63]],
[0.700, [225, 83, 51]],
[0.800, [174, 24, 28]],
[1.000, [141, 6, 22]],
];
function arcColorAt(f) {
for (let i = 0; i < ARC_GRAD.length - 1; i++) {
const [f1, c1] = ARC_GRAD[i], [f2, c2] = ARC_GRAD[i + 1];
if (f <= f2) {
const t = (f - f1) / (f2 - f1);
return `rgb(${Math.round(c1[0]+(c2[0]-c1[0])*t)},${Math.round(c1[1]+(c2[1]-c1[1])*t)},${Math.round(c1[2]+(c2[2]-c1[2])*t)})`;
}
}
return 'rgb(141,6,22)';
}
const arcR = R - 18;
const gradArc = Array.from({ length: 90 }, (_, i) => {
const f1 = i / 90, f2 = (i + 1) / 90;
const [x1, y1] = fracToXY(f1, arcR);
const [x2, y2] = fracToXY(f2, arcR);
return { d: `M ${x1} ${y1} A ${arcR} ${arcR} 0 0 1 ${x2} ${y2}`, color: arcColorAt((f1+f2)/2) };
});
let needleX = cx, needleY = cy + 54;
if (value != null) {
const frac = Math.min(1, Math.max(0, (value + 10) / 60));
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
needleX = cx + 54 * Math.cos(rad);
needleY = cy + 54 * Math.sin(rad);
}
const glowColor = cat ? cat.bg : '#c8922a';
// --- Precip FX canvas --------------------------------------------------
// Animated rain/snow/lightning composited over the lens glass. The rAF
// loop reads live values from fxRef so it never restarts on re-render.
const canvasRef = useRef(null);
const fxRef = useRef({});
fxRef.current = { rain: rainRate, snow: snowRate, storm: !!stormy, heavy, wind: windSpeed, gust: gustSpeed, windH };
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
let raf, w = 0, hgt = 0;
const drops = [], flakes = [], winds = [];
let flash = 0, gustPulse = 0;
function resize() {
const dpr = window.devicePixelRatio || 1;
w = canvas.clientWidth || 200;
hgt = canvas.clientHeight || 200;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(hgt * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
function frame() {
const fx = fxRef.current;
ctx.clearRect(0, 0, w, hgt);
// Wind — base speed with random gust pulses ramping up to gust speed.
// gustPulse snaps to 1 on trigger then decays quickly so gusts feel punchy.
if (fx.wind > 3 && Math.random() < 0.005 + fx.wind * 0.001) gustPulse = 1;
gustPulse = Math.max(0, gustPulse - 0.022);
const effWind = fx.wind + gustPulse * Math.max(0, fx.gust - fx.wind);
const hv = fx.windH * effWind * 0.5; // horizontal px/frame
// Wind particles — faint horizontal streaks drifting across the lens,
// stretching and brightening during gusts.
const windTarget = fx.wind > 1.5 ? Math.min(6, Math.round(fx.wind * 0.45)) : 0;
while (winds.length < windTarget) winds.push({ x: Math.random() * w, y: Math.random() * hgt, r: 1.6 + Math.random() * 1.4, spd: 0.7 + Math.random() * 0.6, ph: Math.random() * 6.28 });
if (winds.length > windTarget) winds.length = windTarget;
if (winds.length) {
ctx.fillStyle = 'rgba(236,243,251,' + (0.22 + gustPulse * 0.22).toFixed(3) + ')';
for (const p of winds) {
const vx = hv * p.spd;
const rad = p.r * (1 + gustPulse * 0.4);
const m = rad + 2;
p.x += vx; p.ph += 0.05; p.y += Math.sin(p.ph) * 0.5; // drift across with a gentle bob
if (vx >= 0 && p.x > w + m) { p.x = -m; p.y = Math.random() * hgt; }
else if (vx < 0 && p.x < -m) { p.x = w + m; p.y = Math.random() * hgt; }
else if (p.y > hgt + m) { p.y = -m; } else if (p.y < -m) { p.y = hgt + m; }
ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, 7); ctx.fill();
}
}
// Rain — count scales with mm/h; wind adds horizontal weight so the
// streaks lean and travel across in the wind direction.
const rainTarget = Math.min(90, Math.round(fx.rain * 13));
while (drops.length < rainTarget) drops.push({ x: Math.random() * w, y: Math.random() * hgt, l: 8 + Math.random() * 10, s: 4.5 + Math.random() * 3.5 });
if (drops.length > rainTarget) drops.length = rainTarget;
if (drops.length) {
const lean = hv * 1.6 + (fx.heavy ? 1 : 0.6);
ctx.strokeStyle = 'rgba(180,205,232,0.5)';
ctx.lineWidth = 1; ctx.lineCap = 'round';
ctx.beginPath();
for (const d of drops) {
ctx.moveTo(d.x, d.y); ctx.lineTo(d.x - lean, d.y - d.l);
d.y += d.s; d.x += hv * 0.9;
if (d.y > hgt + 10) { d.y = -10; d.x = Math.random() * w; }
else if (d.x < -12) { d.x = w + 10; d.y = Math.random() * hgt * 0.7; }
else if (d.x > w + 12) { d.x = -10; d.y = Math.random() * hgt * 0.7; }
}
ctx.stroke();
}
// Snow — drifting flakes; wind carries them across too.
const snowTarget = Math.min(70, Math.round(fx.snow * 45));
while (flakes.length < snowTarget) flakes.push({ x: Math.random() * w, y: Math.random() * hgt, r: 1 + Math.random() * 1.8, vy: 0.5 + Math.random() * 0.9, p: Math.random() * 6.28 });
if (flakes.length > snowTarget) flakes.length = snowTarget;
if (flakes.length) {
ctx.fillStyle = 'rgba(255,255,255,0.85)';
for (const f of flakes) {
f.y += f.vy; f.p += 0.02; f.x += Math.sin(f.p) * 0.4 + hv * 0.6;
if (f.y > hgt + 5) { f.y = -5; f.x = Math.random() * w; }
else if (f.x < -6) { f.x = w + 5; }
else if (f.x > w + 6) { f.x = -5; }
ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill();
}
}
// Lightning — occasional flash during storms/heavy precip.
if (fx.storm && Math.random() < 0.004) flash = 1;
if (flash > 0) {
ctx.fillStyle = 'rgba(230,238,255,' + (flash * 0.45).toFixed(3) + ')';
ctx.fillRect(0, 0, w, hgt);
flash -= 0.08;
}
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}, []);
return html`
<div class="scope-wrap">
<svg viewBox="0 0 200 200" class="scope-ring" aria-label="Current UTCI scope readout">
<defs>
<radialGradient id="scope-bg-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color=${glowColor} stop-opacity="0.12" />
<stop offset="100%" stop-color=${glowColor} stop-opacity="0" />
</radialGradient>
<!-- 3D lens: inner rim shadow — dark around the edge, transparent centre -->
<radialGradient id="scope-rim-shadow" cx="50%" cy="50%" r="50%">
<stop offset="72%" stop-color="transparent" />
<stop offset="100%" stop-color="rgba(20,10,0,0.38)" />
</radialGradient>
<!-- 3D lens: specular highlight — bright crescent top-left -->
<radialGradient id="scope-specular" cx="36%" cy="28%" r="42%">
<stop offset="0%" stop-color="rgba(255,255,255,0.28)" />
<stop offset="55%" stop-color="rgba(255,255,255,0.06)" />
<stop offset="100%" stop-color="rgba(255,255,255,0)" />
</radialGradient>
<!-- 3D lens: subtle bottom-right counter-glow for depth -->
<radialGradient id="scope-depth" cx="72%" cy="76%" r="38%">
<stop offset="0%" stop-color="rgba(180,120,20,0.10)" />
<stop offset="100%" stop-color="rgba(180,120,20,0)" />
</radialGradient>
<!-- Specular glint: feathered radial gradient, top-left -->
<radialGradient id="scope-glint" gradientUnits="userSpaceOnUse"
cx="62" cy="52" r="24" fx="62" fy="50">
<stop offset="0%" stop-color="rgba(255,255,255,0.80)" />
<stop offset="30%" stop-color="rgba(255,255,255,0.30)" />
<stop offset="100%" stop-color="rgba(255,255,255,0)" />
</radialGradient>
<clipPath id="scope-lens-clip">
<circle cx=${cx} cy=${cy} r=${lensR} />
</clipPath>
<linearGradient id="scope-lens-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${skyTopTint} style="transition:stop-color 2s ease" />
<stop offset="100%" stop-color=${skyBotTint} style="transition:stop-color 2s ease" />
</linearGradient>
<linearGradient id="scope-lens-grass" x1="0" y1="0" x2="0" y2="1">
${groundStops.map((s, i) => html`<stop key=${i} offset=${s.off + '%'} stop-color=${s.col} style="transition:stop-color 2s ease" />`)}
</linearGradient>
<!-- Soft scrim behind the readout so the numbers stay legible over busy ground -->
<radialGradient id="scope-readout-scrim" gradientUnits="userSpaceOnUse" cx=${cx} cy="142" r="50">
<stop offset="0%" stop-color=${scrimColor} stop-opacity="0.3" />
<stop offset="60%" stop-color=${scrimColor} stop-opacity="0.2" />
<stop offset="100%" stop-color=${scrimColor} stop-opacity="0" />
</radialGradient>
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
<radialGradient id="scope-sun-glow" gradientUnits="userSpaceOnUse"
cx=${cx} cy=${sunY} r=${sunGlowR}>
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
<stop offset=${`${Math.round(sunDrawR / sunGlowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
<stop offset="70%" stop-color="#ffe060" stop-opacity=${sunGlowPeak * 0.5} />
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: white = lit area, black = shadow punched out -->
<mask id="scope-moon-mask">
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="white" />
<circle cx=${moonShadowCx} cy=${moonY} r=${moonDrawR} fill="black" />
</mask>
<!-- Moon texture pattern - sized to fit the landscape moon disk -->
<pattern id="scope-moon-tex" x=${cx - moonDrawR - 2} y=${moonY - moonDrawR - 2}
width=${moonDrawR * 2 + 4} height=${moonDrawR * 2 + 4} patternUnits="userSpaceOnUse">
<image href=${MOON_TEX} x="0" y="0" width=${moonDrawR * 2 + 4} height=${moonDrawR * 2 + 4} />
</pattern>
</defs>
<circle cx=${cx} cy=${cy} r="96" fill="url(#scope-bg-glow)" />
<!-- Full sky + grass + sun/moon scene behind the readout -->
<g clip-path="url(#scope-lens-clip)">
<circle cx=${cx} cy=${cy} r=${lensR} fill="url(#scope-lens-sky)" />
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
fill="url(#scope-lens-grass)" />
${isDay && html`<${Fragment}>
<!-- Glow + sun disk as one seamless radial gradient circle -->
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)" />
</>`}
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
<!-- Lit crescent — full moon disk clipped by the phase mask -->
<circle cx=${cx} cy=${moonY} r=${moonDrawR}
fill="#e7e3d4" mask="url(#scope-moon-mask)" />
<!-- Texture overlay on lit face -->
<circle cx=${cx} cy=${moonY} r=${moonDrawR}
fill="url(#scope-moon-tex)"
mask="url(#scope-moon-mask)"
opacity="0.55" />
<!-- Dim unlit face so the full disk outline is still visible -->
<circle cx=${cx} cy=${moonY} r=${moonDrawR}
fill="rgba(200,192,230,0.08)" stroke="none" />
</>`}
${groundShapes}
<circle cx=${cx} cy="142" r="50" fill="url(#scope-readout-scrim)" />
<g key="scope-wx">
${cloudLayers}
<rect x="0" y="0" width="200" height="200" fill=${`rgb(${fogColor})`}
opacity=${fogAlpha.toFixed(2)} style="transition:opacity 2s ease" />
</g>
</g>
${(() => {
const overlaySVG = getLensOverlaySVG(activeEvent, cx, cy, lensR);
if (!overlaySVG) return null;
return html`<g dangerouslySetInnerHTML=${{ __html: overlaySVG }} />`;
})()}
${gradArc.map((seg, i) => html`
<path key=${i} d=${seg.d} fill="none"
stroke=${seg.color} stroke-width="4" stroke-linecap="butt" />`)}
<circle cx=${cx} cy=${cy} r=${R} fill="none" stroke="#c9b08a" stroke-width="1.5" />
${ticks.map((t, i) => html`
<line key=${i} x1=${t.x1} y1=${t.y1} x2=${t.x2} y2=${t.y2}
stroke=${t.major ? '#c8922a' : t.medium ? '#c9b08a' : '#e0d0b0'}
stroke-width=${t.major ? 1.5 : 0.75} />`)}
<line x1=${cx - R + 3} y1=${cy} x2=${cx - 32} y2=${cy} stroke="#d4b896" stroke-width="0.75" />
<line x1=${cx + 32} y1=${cy} x2=${cx + R - 3} y2=${cy} stroke="#d4b896" stroke-width="0.75" />
<line x1=${cx} y1=${cy - R + 3} x2=${cx} y2=${cy - 32} stroke="#d4b896" stroke-width="0.75" />
<line x1=${cx} y1=${cy + 32} x2=${cx} y2=${cy + R - 3} stroke="#d4b896" stroke-width="0.75" />
<circle cx=${cx} cy=${cy} r="58" fill="none" stroke="#e8d8c0" stroke-width="0.75" />
<circle cx=${cx} cy=${cy} r="32" fill="none" stroke="#e8d8c0" stroke-width="0.5" />
${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html`
<g key=${i}>
<line x1=${cx + sx*72} y1=${cy + sy*72} x2=${cx + sx*60} y2=${cy + sy*72}
stroke="#c9b08a" stroke-width="1" />
<line x1=${cx + sx*72} y1=${cy + sy*72} x2=${cx + sx*72} y2=${cy + sy*60}
stroke="#c9b08a" stroke-width="1" />
</g>`)}
${value != null && html`
<${Fragment}>
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
stroke="#c8922a" stroke-width="5" stroke-linecap="round" opacity="0.18" />
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
stroke="#c8922a" stroke-width="2.5" stroke-linecap="round" opacity="0.95" />
</>`}
<circle cx=${cx} cy=${cy} r="5.5" fill="#f5edd6" stroke="#c8922a" stroke-width="1.5" />
<circle cx=${cx} cy=${cy} r="2.5" fill="#c8922a" />
<!-- 3D lens overlays: rim shadow + specular highlight + depth, all on top -->
<circle cx=${cx} cy=${cy} r=${lensR} fill="url(#scope-rim-shadow)" pointer-events="none" />
<circle cx=${cx} cy=${cy} r=${lensR} fill="url(#scope-specular)" pointer-events="none" />
<circle cx=${cx} cy=${cy} r=${lensR} fill="url(#scope-depth)" pointer-events="none" />
<!-- Specular glint: smooth feathered glow top-left, like light on curved glass -->
<circle cx="62" cy="52" r="24" fill="url(#scope-glint)" pointer-events="none" />
<!-- Outer brass bezel ring with a subtle 3D bevel -->
<circle cx=${cx} cy=${cy} r="97" fill="none"
stroke="rgba(255,220,140,0.45)" stroke-width="1.5" pointer-events="none" />
<circle cx=${cx} cy=${cy} r="99" fill="none"
stroke="rgba(80,40,0,0.25)" stroke-width="1.5" pointer-events="none" />
${loading
? html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill=${readoutMutedColor} font-size="11" font-family="monospace">· · ·</text>`
: value != null
? html`<${Fragment}>
<text x=${cx} y="152" text-anchor="middle"
fill=${readoutColor} font-size="26"
font-family="Fraunces, serif" font-weight="700">
${value.toFixed(1)}°
</text>
<text x=${cx} y=${cy + 14} text-anchor="middle"
fill=${readoutColor} font-size="8"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="1.2">
SUNSOAK INDEX
</text>
<text x=${cx} y=${cy + 23} text-anchor="middle"
fill=${glowColor} font-size="9"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.5">
${cat.label.toUpperCase()}
</text>
${utciEnvShort && html`
<${Fragment}>
<rect x=${cx - 34} y="158" width="68" height="11" rx="3"
fill=${glowColor} opacity="0.22" />
<text x=${cx} y="166" text-anchor="middle"
fill=${readoutColor} font-size="7"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.6">
${utciEnvShort.toUpperCase()}
</text>
</>`}
</>`
: html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill=${readoutMutedColor} font-size="8"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.8">
AWAITING
</text>`}
</svg>
<canvas ref=${canvasRef} class="scope-fx" aria-hidden="true"></canvas>
</div>`;
}
// -------------------------------------------------------------------
// PRECIPICON - rain drop or snowflake SVG for the precip cell.
// -------------------------------------------------------------------
// precip - mm/h rainfall
// snow - cm/h snowfall
// size - pixel size (default 28 to match CloudIcon)
//
// Intensity bands:
// dry - faint dash
// light rain (< 1 mm/h) - 1 drop
// moderate rain(< 4 mm/h) - 2 drops
// heavy rain (- 4 mm/h) - 3 drops
// light snow (< 0.5cm/h) - 1 flake
// heavy snow (- 0.5cm/h) - 2 flakes
// mixed - 1 drop + 1 flake
// -------------------------------------------------------------------
// drops - optional override for the rain-drop count (1-3). The day tabs
// pass this because their intensity is a blend of amount AND
// forecast probability, not amount alone (see the "two rain
// numbers" block in components/DayTabs.js). Callers that only have
// an mm/h figure omit it and get the amount-derived bands above.
export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false, drops = null }) {
const r = size / 2;
const hasRain = precip > 0;
const hasSnow = snow > 0;
const mixed = hasRain && hasSnow;
const brass = '#c8922a';
const ink = '#2a1a08';
const rain = '#2a6a90';
const snowBlue = '#3f73c4';
const sw = Math.max(1.25, size * 0.055);
const cloud = html`
<path d=${`M ${size*0.18} ${size*0.36}
H ${size*0.72}
C ${size*0.86} ${size*0.36}, ${size*0.88} ${size*0.20}, ${size*0.72} ${size*0.19}
C ${size*0.66} ${size*0.04}, ${size*0.42} ${size*0.02}, ${size*0.34} ${size*0.17}
C ${size*0.22} ${size*0.14}, ${size*0.14} ${size*0.24}, ${size*0.18} ${size*0.36} Z`}
fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />`;
const rainStroke = (cx, cy, heavy = false) => html`
<line x1=${cx + size * 0.04} y1=${cy} x2=${cx - size * 0.04} y2=${cy + size * 0.18}
stroke=${rain} stroke-width=${heavy ? sw * 1.1 : sw}
stroke-linecap="round" />`;
const flake = (cx, cy, fr) => html`
<g stroke=${snowBlue} stroke-width=${Math.max(1, size * 0.045)} stroke-linecap="round">
<line x1=${cx - fr} y1=${cy} x2=${cx + fr} y2=${cy} />
<line x1=${cx} y1=${cy - fr} x2=${cx} y2=${cy + fr} />
<line x1=${cx - fr * 0.68} y1=${cy - fr * 0.68} x2=${cx + fr * 0.68} y2=${cy + fr * 0.68} />
<line x1=${cx + fr * 0.68} y1=${cy - fr * 0.68} x2=${cx - fr * 0.68} y2=${cy + fr * 0.68} />
</g>`;
// Dry - just a faint dash
if (!hasRain && !hasSnow) {
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
<line x1=${r - size * 0.2} y1=${r} x2=${r + size * 0.2} y2=${r}
stroke=${brass} stroke-width="1.5" stroke-linecap="round" opacity="0.75" />
</svg>`;
}
// Rain intensity - number of drops
const rainDrops = !hasRain ? 0
: drops != null ? Math.max(1, Math.min(3, drops))
: precip < 1 ? 1 : precip < 4 ? 2 : 3;
// Snow intensity - number of flakes
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
const fr = size * 0.08; // flake arm length
// Layout: spread items evenly across the icon width
const items = (mixed ? rainDrops + snowFlakes : hasRain ? rainDrops : snowFlakes);
const spacing = size / (items + 1);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${cloud}
${mixed
? html`
${Array.from({ length: rainDrops }, (_, i) => html`
<${Fragment} key=${'d' + i}>
${rainStroke(spacing * (i + 1), size * 0.52, false)}
</>`)}
${Array.from({ length: snowFlakes }, (_, i) =>
flake(spacing * (rainDrops + i + 1), size * 0.68, fr)
)}`
: hasRain
? Array.from({ length: rainDrops }, (_, i) => html`
<${Fragment} key=${i}>
${rainStroke(spacing * (i + 1), size * 0.53, rainDrops >= 3)}
</>`)
: Array.from({ length: snowFlakes }, (_, i) =>
flake(spacing * (i + 1), size * 0.66, fr)
)}
</svg>`
}
// HOUSEICON / CARICON - Brass Line glyphs for the day-tab shade when a
// modelled indoor or vehicle-cabin temperature stands in for the outdoor
// weather icon.
// -------------------------------------------------------------------
export function HouseIcon({ size = 30 }) {
const sw = Math.max(1.25, size * 0.058);
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.13} ${size*0.52}
L ${size*0.50} ${size*0.15}
L ${size*0.87} ${size*0.52}`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.22} ${size*0.46}
V ${size*0.85}
H ${size*0.78}
V ${size*0.46}`}
fill="none" stroke=${ink} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.42} ${size*0.85}
V ${size*0.60}
H ${size*0.58}
V ${size*0.85}`}
fill="none" stroke=${ink} stroke-width=${sw*0.8} stroke-linecap="round" stroke-linejoin="round" />
</svg>`;
}
// Body path is Lucide's "car" icon (ISC licence), used verbatim on its
// native 24x24 grid rather than hand-plotted, since freehand coordinates
// kept coming out lopsided.
export function CarIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"
stroke=${brass} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 17h6" stroke=${ink} stroke-width="1.8" stroke-linecap="round" />
<circle cx="7" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
<circle cx="17" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
</svg>`;
}
// FOGICON - reuses the same closed puffy-cloud outline as PrecipIcon's
// cloud (rather than a foreign icon-set shape) so it sits consistently
// alongside the rain/snow glyphs, with mist bands underneath.
export function FogIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
const sw = Math.max(1.25, size * 0.055);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.18} ${size*0.36}
H ${size*0.72}
C ${size*0.86} ${size*0.36}, ${size*0.88} ${size*0.20}, ${size*0.72} ${size*0.19}
C ${size*0.66} ${size*0.04}, ${size*0.42} ${size*0.02}, ${size*0.34} ${size*0.17}
C ${size*0.22} ${size*0.14}, ${size*0.14} ${size*0.24}, ${size*0.18} ${size*0.36} Z`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<line x1=${size*0.14} y1=${size*0.46} x2=${size*0.86} y2=${size*0.46}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.22} y1=${size*0.58} x2=${size*0.78} y2=${size*0.58}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.10} y1=${size*0.70} x2=${size*0.90} y2=${size*0.70}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" opacity="0.7" />
</svg>`;
}
// WINDICON - Brass Line gust glyph for the day-tab wind badge. Body paths are
// Lucide's "wind" icon (ISC licence) on its native 24x24 grid, same approach
// as CarIcon. Gale-force and above (tier >= 3) swap the brass/ink pairing for
// a colder storm blue so the step up from "blustery" is visible at a glance.
// -------------------------------------------------------------------
// halo - draws each gust line twice, a wider white stroke underneath the
// coloured one. Needed when the glyph is overlaid on the cloud/rain
// icon: without it the two line drawings tangle into one another and
// neither reads. Costs nothing when drawn on a flat tab background.
export function WindIcon({ size = 30, tier = 2, halo = false }) {
const severe = tier >= 3;
const lead = severe ? '#2f5d82' : '#c8922a';
const trail = severe ? '#3a6a92' : '#2a1a08';
const gusts = [
{ d: 'M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2', stroke: lead },
{ d: 'M9.6 4.6A2 2 0 1 1 11 8H2', stroke: trail },
{ d: 'M12.6 19.4A2 2 0 1 0 14 16H2', stroke: trail },
];
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<g stroke-linecap="round" stroke-linejoin="round">
${halo && gusts.map((p, i) => html`
<path key=${'h' + i} d=${p.d} stroke="#fdf6e8" stroke-width="4.4" opacity="0.92" />`)}
${gusts.map((p, i) => html`
<path key=${i} d=${p.d} stroke=${p.stroke} stroke-width="1.9" />`)}
</g>
</svg>`;
}
export function IceIcon({ size = 30 }) {
const icy = '#3f73c4';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<g stroke=${icy} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m10 20-1.25-2.5L6 18" />
<path d="M10 4 8.75 6.5 6 6" />
<path d="m14 20 1.25-2.5L18 18" />
<path d="m14 4 1.25 2.5L18 6" />
<path d="m17 21-3-6h-4" />
<path d="m17 3-3 6 1.5 3" />
<path d="M2 12h6.5L10 9" />
<path d="m20 10-1.5 2 1.5 2" />
<path d="M22 12h-6.5L14 15" />
<path d="m4 10 1.5 2L4 14" />
<path d="m7 21 3-6-1.5-3" />
<path d="m7 3 3 6h4" />
</g>
</svg>`;
}