Files
sunscope/assets/js/components.js
T
fraxle 6952f0e1c0 1.9.3.2
Moon phase fix 2
2026-05-27 14:29:01 +01:00

747 lines
36 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
// 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, 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, grpClass }) {
const [open, setOpen] = useState(false);
const [flipRight, setFlipRight] = useState(false);
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, check if it overflows the right viewport edge.
// If so, flip it to right-align; otherwise keep it left-aligned.
useEffect(() => {
if (!open) { setFlipRight(false); return; }
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
setFlipRight(rect.right > window.innerWidth - 8);
}, [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(' ');
return html`
<span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}>
<button
class=${btnClass}
onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}
type="button"
>
<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${flipRight ? ' cs-panel--right' : ''}`} ref=${panelRef} 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, 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>`;
}
// -------------------------------------------------------------------
// 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>
</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, utciEnvShort = 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 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';
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 }} />`;
})()}
${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>`;
}
// -------------------------------------------------------------------
// 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>`;
}