// ------------------------------------------------------------------------ // 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` { e.stopPropagation(); setOpen(o => !o); }} type="button" > ${btnLabel} ${open && html` ${isOn && !noHide && html` { onChange('off'); setOpen(false); }} >${hidingLabel || ('Hide ' + hideLabel)} `} ${options.map(opt => opt.divider ? html`` : 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` ${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` ${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 - 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` `; const mist = (y0, color = muted) => html` ${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)} `; // 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``; }); return html` ${rays} `; }; return html` ${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` ${line(size * 0.20, size * 0.58, size * 0.48, size * 0.58, brass, sw * 0.75, 0.78)}`} ${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, 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(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, }; }); 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}> >`} ${(() => { const overlaySVG = getLensOverlaySVG(activeEvent, cx, cy, lensR); if (!overlaySVG) return null; return html``; })()} ${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; const brass = '#c8922a'; const ink = '#2a1a08'; const rain = '#2a6a90'; const snowBlue = '#3f73c4'; const sw = Math.max(1.25, size * 0.055); const cloud = html` `; const rainStroke = (cx, cy, heavy = false) => html` `; const flake = (cx, cy, fr) => 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 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` ${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) )} `; }