// ════════════════════════════════════════════════════════════════════════ // components.js — Preact UI components (SVG widgets). // // Exports: // SkyScope({ elev, dt, size }) little porthole circle per hour // WindVane({ bearing, size }) compass arrow // CloudIcon({ category, size, elev, dt }) cloud/sun/moon icon // ScopeReticle({ value, cat, loading, elev, dt }) big UTCI dial // PrecipIcon({ precip, snow, size }) rain/snow icon for precip cell // // All components use the html`` tagged template from htm + Preact. // They depend on utils.js (skyFillForElev, grassFillForElev, // moonPhaseFraction) but have no other external state. // ════════════════════════════════════════════════════════════════════════ 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'; 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, 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 = isOn ? (activeOpt ? activeOpt.label : hideLabel) : hideLabel; const btnClass = [ 'cs-btn', isOn ? 'on' : '', groupedLeft ? 'grouped-left' : '', (groupedLeft && isLastChild) ? 'last-child' : '', ].filter(Boolean).join(' '); return html` ${open && html`
${isOn && !noHide && html` { onChange('off'); setOpen(false); }} >${hidingLabel || ('Hide ' + hideLabel)}
`} ${options.map(opt => html` { if (opt.disabled) return; onChange(opt.value); setOpen(false); }} >${opt.label} `)}
`}
`; } // ═══════════════════════════════════════════════════════════════════ // 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` `; } // ═══════════════════════════════════════════════════════════════════ // 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); // Flat horizon colour used for moon shadow fill const skyHorizon = skyGrad.bot; 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 glowColor = isRising ? '#ffb040' : '#ffc060'; 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` ${isDay && html` `} ${(isTwil || isNight) && html` `} `; } // ═══════════════════════════════════════════════════════════════════ // 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``; } 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` N `; } // ═══════════════════════════════════════════════════════════════════ // CLOUDICON — clouds + sun/moon on a fully transparent background. // No rim, no disc. Day/night aware: when elev < 0, the sun is // replaced with a phased moon (same logic as SkyScope). // ─────────────────────────────────────────────────────────────────── // 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 u = r; // half-extent for layout const isNight = elev < -3; // Phased moon helper — returns an SVG with the moon + shadow. // Lit fraction direction matches SkyScope (waxing = lit from right). const renderMoon = (cx, cy, moonR) => { const phase = moonPhaseFraction(dt); const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; const moonLitFromRight = phase < 0.5; const shadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset); const clipId = `cmoon-${size}-${Math.round(cx)}-${Math.round(cy)}-${Math.round(phase * 1000)}`; return html` `; }; return html` ${category === 'clear' && (isNight ? html` ${renderMoon(r, r, u*0.50)}` : html` ${[0,45,90,135,180,225,270,315].map(a => { const x1 = r + Math.sin(a*Math.PI/180) * u*0.65; const y1 = r - Math.cos(a*Math.PI/180) * u*0.65; const x2 = r + Math.sin(a*Math.PI/180) * u*0.95; const y2 = r - Math.cos(a*Math.PI/180) * u*0.95; return html``; })} `)} ${category === 'wispy' && html` ${isNight ? renderMoon(r, r*0.95, u*0.42) : html``} `} ${category === 'scattered' && html` ${isNight ? renderMoon(r+u*0.45, r-u*0.40, u*0.32) : html``} `} ${category === 'overcast' && html` `} `; } // ═══════════════════════════════════════════════════════════════════ // 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 }) { // ── 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` ${isDay && html`<${Fragment}> `} ${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}> `} ${stressBands.map((b, i) => html` `)} ${ticks.map((t, i) => html` `)} ${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html` `)} ${value != null && html` <${Fragment}> `} ${loading ? html`· · ·` : value != null ? html`<${Fragment}> ${value.toFixed(1)}° UTCI NOW ${cat.label.toUpperCase()} ` : html` AWAITING `} `; } // ═══════════════════════════════════════════════════════════════════ // 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; // Drop shape: teardrop path centred on (cx, cy), radius dr const drop = (cx, cy, dr) => { const tip = cy + dr * 1.55; const cw = dr * 0.72; return `M ${cx} ${tip} C ${cx - cw} ${cy + dr * 0.6}, ${cx - dr} ${cy - dr * 0.3}, ${cx} ${cy - dr} C ${cx + dr} ${cy - dr * 0.3}, ${cx + cw} ${cy + dr * 0.6}, ${cx} ${tip} Z`; }; // Six-pointed snowflake: centre lines + diagonal lines const flake = (cx, cy, fr) => html` ${[-1, 1].map(sx => [-1, 1].map(sy => html` `))} `; // Dry — just a faint dash if (!hasRain && !hasSnow) { return html` `; } // 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 dropColor = '#5090b0'; const dr = size * 0.13; // drop radius const fr = size * 0.18; // 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` ${mixed ? html` ${Array.from({ length: rainDrops }, (_, i) => html` `)} ${Array.from({ length: snowFlakes }, (_, i) => flake(spacing * (rainDrops + i + 1), r, fr) )}` : hasRain ? Array.from({ length: rainDrops }, (_, i) => html` `) : Array.from({ length: snowFlakes }, (_, i) => flake(spacing * (i + 1), r, fr) )} `; }