Files
sunscope/assets/js/components.js
T

719 lines
37 KiB
JavaScript

// ════════════════════════════════════════════════════════════════════════
// 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
// 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 })
// large UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon
// ════════════════════════════════════════════════════════════════════════
import { h, Fragment } from '../vendor/preact.js';
import { useState, useEffect, 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);
// ═══════════════════════════════════════════════════════════════════
// 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
// ═══════════════════════════════════════════════════════════════════
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild }) {
const [open, setOpen] = useState(false);
const wrapRef = 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]);
// 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',
isOn ? 'on' : '',
groupedLeft ? 'grouped-left' : '',
(groupedLeft && isLastChild) ? 'last-child' : '',
].filter(Boolean).join(' ');
return html`
<span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}>
<button
class=${btnClass}
onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}
type="button"
>
${btnLabel}
<span class="cs-arrow"></span>
</button>
${open && html`
<div class="cs-panel" role="listbox">
${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 }) {
return html`
<label class=${`cs-vent${checked ? ' on' : ''}`} 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>`;
}
// ═══════════════════════════════════════════════════════════════════
// 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 phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
const moonLitFromRight = phase < 0.5;
const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset);
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>
</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="#f5edd6" mask=${`url(#moon-mask-${uid})`} />
<!-- Dim unlit face so the full disk outline remains visible -->
<circle cx=${r} cy=${r} r=${moonR}
fill="rgba(180,170,210,0.18)" stroke="rgba(245,237,214,0.45)" stroke-width="0.4" />
</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.
// No sun or moon here; the time-cell SkyScope owns day/night imagery.
// ───────────────────────────────────────────────────────────────────
// 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() }) {
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' && html`
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'wispy' && 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' && html`
<path d=${cloudPath(0, -size * 0.2425, 0.82)} fill="none" stroke=${ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
${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="none" stroke=${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="none" stroke=${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 }) {
// ── 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;
const grass = grassFillForElev(elev);
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 phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR;
const moonLitFromRight = phase < 0.5;
const moonShadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
// 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,
};
});
const stressBands = [
{ min: -40, max: -27, color: '#23408f' },
{ min: -27, max: -13, color: '#3f73c4' },
{ min: -13, max: 0, color: '#7eb0e0' },
{ min: 0, max: 9, color: '#bcd9ec' },
{ min: 9, max: 18, color: '#c8dcc0' },
{ min: 18, max: 26, color: '#6ab05a' },
{ min: 26, max: 32, color: '#e8c547' },
{ min: 32, max: 38, color: '#dc8a3a' },
{ min: 38, max: 46, color: '#c44a3a' },
{ min: 46, max: 50, color: '#7a1a1a' },
];
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)];
}
function bandArcPath(band) {
const f1 = Math.min(1, Math.max(0, (band.min + 10) / 60));
const f2 = Math.min(1, Math.max(0, (band.max + 10) / 60));
const arcR = R - 18;
const [x1, y1] = fracToXY(f1, arcR);
const [x2, y2] = fracToXY(f2, arcR);
const large = (f2 - f1) * 270 > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${arcR} ${arcR} 0 ${large} 1 ${x2} ${y2}`;
}
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';
return html`
<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=${skyGrad.top} />
<stop offset="100%" stop-color=${skyGrad.bot} />
</linearGradient>
<linearGradient id="scope-lens-grass" 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="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>
</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="#f5edd6" mask="url(#scope-moon-mask)" />
<!-- Dim unlit face so the full disk outline is still visible -->
<circle cx=${cx} cy=${moonY} r=${moonDrawR}
fill="rgba(180,170,210,0.18)" stroke="rgba(245,237,214,0.45)" stroke-width="0.7" />
</>`}
</g>
${(() => {
const overlaySVG = getLensOverlaySVG(activeEvent, cx, cy, lensR);
if (!overlaySVG) return null;
return html`<g dangerouslySetInnerHTML=${{ __html: overlaySVG }} />`;
})()}
${stressBands.map((b, i) => html`
<path key=${i} d=${bandArcPath(b)} fill="none"
stroke=${b.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 + 15} text-anchor="middle"
fill=${readoutMutedColor} font-size="6.5"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="1.2">
UTCI NOW
</text>
<text x=${cx} y=${cy + 23} text-anchor="middle"
fill=${glowColor} font-size="7"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.5">
${cat.label.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>`;
}
// ═══════════════════════════════════════════════════════════════════
// 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
// ═══════════════════════════════════════════════════════════════════
export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
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="none" stroke=${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 : 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, precip >= 4)}
</>`)
: Array.from({ length: snowFlakes }, (_, i) =>
flake(spacing * (i + 1), size * 0.66, fr)
)}
</svg>`;
}