Updated scope temperature arc to gradient Fix banner scaling Add Pulldown to scope
749 lines
36 KiB
JavaScript
749 lines
36 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, 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 phaseOffset = Math.cos(Math.PI * phase) * moonR;
|
||
const moonLitFromRight = phase < 0.5;
|
||
const shadowCx = r + 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, 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 phaseOffset = Math.cos(Math.PI * phase) * moonDrawR;
|
||
const moonLitFromRight = phase < 0.5;
|
||
const moonShadowCx = cx + 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,
|
||
};
|
||
});
|
||
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>`;
|
||
}
|