// ------------------------------------------------------------------------ // 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); // --- Live-weather scope assets/helpers ----------------------------------- // Soft transparent cloud sprite, layered + drifted inside the big lens. const CLOUD_IMG = 'assets/images/tinycloud.png'; // Linear blend between two #rrggbb colours (t = 0 → a, 1 → b). Used to grey // out / darken the lens sky as cloud cover and precipitation increase. function _hexRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function blendHex(a, b, t) { t = Math.max(0, Math.min(1, t)); const A = _hexRgb(a), B = _hexRgb(b); return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join(''); } // Tiny seeded PRNG (mulberry32). Deterministic so the scattered ground // objects keep the same positions on every re-render (no jitter). function seededRng(seed) { let s = seed >>> 0; return function () { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), 1 | t); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // Per-environment ground: a daytime gradient (top = horizon → bottom = // foreground; some split into bands), a night target colour, and an object // set. All colours are run through one cycle model at render time (darken by // elevation, tint toward the live sky, grey out in bad weather, whiten land // when snowing). `obj.bands` constrains where objects may sit (e.g. beach // palms on sand, river reeds on the banks not the water). const GROUND_SPEC = { open: { night: '#0e160b', grad: [[0, '#6f9e3e'], [1, '#3f6a24']], obj: { types: ['hedge', 'bush', 'oak'], base: 18, n: 7, sizeMul: 1.15, seed: 1011, bands: [[0.14, 0.92]] } }, forest: { night: '#0d120a', grad: [[0, '#3f5a2e'], [0.5, '#4a4628'], [1, '#3a2c19']], obj: { types: ['oak'], base: 22, n: 11, sizeMul: 1.7, seed: 3031, bands: [[0.1, 0.94]] } }, alpine: { night: '#11141b', snowGround: true, grad: [[0, '#787c82'], [1, '#494c52']], obj: { types: ['pine'], base: 20, n: 7, sizeMul: 1.4, seed: 7073, bands: [[0.12, 0.92]], mountains: true } }, urban: { night: '#13151b', grad: [[0, '#9a9da2'], [1, '#595c61']], obj: { skyline: true, seed: 2023 } }, beach: { night: '#0e1620', grad: [[0, '#3f7a92'], [0.4, '#5a90a4'], [0.46, '#e7d3a0'], [1, '#cdaf78']], obj: { types: ['palm', 'parasol'], base: 26, n: 6, sizeMul: 1.3, seed: 4041, bands: [[0.5, 0.92]] } }, openwater: { night: '#0b1a24', grad: [[0, '#4a7e98'], [1, '#214458']], obj: { types: ['boat'], base: 22, n: 5, sizeMul: 1.2, seed: 6061, bands: [[0.15, 0.9]] } }, desert: { night: '#19140f', grad: [[0, '#d9bd85'], [1, '#b6945c']], obj: { types: ['cactus'], base: 24, n: 6, sizeMul: 1.3, seed: 8083, bands: [[0.12, 0.9]] } }, river: { night: '#0d130a', grad: [[0, '#4d6c30'], [0.26, '#3f5a28'], [0.32, '#42738b'], [0.58, '#356074'], [0.64, '#4d6c30'], [1, '#345020']], obj: { types: ['reed', 'oak'], base: 17, n: 8, sizeMul: 1.3, seed: 5053, bands: [[0.1, 0.24], [0.68, 0.92]] } }, }; // Daytime "material" colour per scattered object type — trees green, cacti // desert-green, parasol warm, boat sail-coloured, etc. Run through the cycle // (objTone) at render time so they darken at night, warm at sunset, etc. const MATERIAL = { oak: '#4f7d39', pine: '#356040', bush: '#2e5218', hedge: '#4c7a3e', cactus: '#5f8a4a', palm: '#4a7d3e', parasol: '#bb5a46', reed: '#74803c', boat: '#ccc4ad', }; const MOON_TEX = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAFEGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjYtMDUtMjdUMTQ6NDI6MDUrMDE6MDAiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDI2LTA1LTI3VDE0OjQ0OjA2KzAxOjAwIgogICB4bXA6TWV0YWRhdGFEYXRlPSIyMDI2LTA1LTI3VDE0OjQ0OjA2KzAxOjAwIgogICBwaG90b3Nob3A6RGF0ZUNyZWF0ZWQ9IjIwMjYtMDUtMjdUMTQ6NDI6MDUrMDE6MDAiCiAgIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiCiAgIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjEyOCIKICAgZXhpZjpQaXhlbFlE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'; // ------------------------------------------------------------------- // 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, sceneStyle, sceneContent }) { 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` ${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, grpClass }) { 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); 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` ${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' && elev >= 0 && html` ${sun(size * 0.50, size * 0.38, size * 0.16)}`} ${category === 'clear' && elev < 0 && html` `} ${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, utciEnvShort = null, cc = null, ccLow = null, ccMid = null, ccHigh = null, precip = 0, snow = 0, visKm = null, env = 'open', wind = 0, gust = 0, wd = 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; // --- Live weather layer ------------------------------------------------ // Continuous, data-driven sky/cloud/precip state (not event-triggered). const ccTotal = cc != null ? cc : 0; const cloudF = Math.min(1, ccTotal / 100); const rainRate = precip != null ? precip : 0; // mm/h const snowRate = snow != null ? snow : 0; // cm/h const windSpeed = wind != null ? wind : 0; // m/s const gustSpeed = (gust != null && gust > windSpeed) ? gust : windSpeed; // Horizontal drift unit from wind direction (meteorological "from" degrees): // the blowing-to east-west component is -sin(wd); + = rightward. Floored so // there's always visible cross-motion even for near-N/S winds. let windH = wd != null ? -Math.sin(wd * Math.PI / 180) : 0.4; if (Math.abs(windH) < 0.35) windH = (windH < 0 ? -1 : 1) * 0.35; const wet = rainRate > 0 || snowRate > 0; const heavy = rainRate >= 4 || snowRate >= 0.5; const stormy = heavy || (activeEvent && activeEvent.id === 'storm'); // Overcast greys the sky; precipitation/storm darkens it further. Night // skies go to a cold near-black rather than daytime flat grey. const greyTarget = isDay ? (stormy ? '#6f767d' : '#a6acb2') : (stormy ? '#161a23' : '#2b303b'); let tintAmt = cloudF * 0.72; if (wet) tintAmt = Math.min(0.88, tintAmt + 0.14); if (heavy) tintAmt = Math.min(0.93, tintAmt + 0.12); const skyTopTint = blendHex(skyGrad.top, isDay ? greyTarget : blendHex(greyTarget, '#0e1018', 0.4), tintAmt * 0.8); const skyBotTint = blendHex(skyGrad.bot, greyTarget, tintAmt); // Drifting cloud sprites, one strip per coverage layer. Each strip holds a // few sprites duplicated +200px so the CSS drift wraps seamlessly. // // These are rendered as real (keyed) Preact nodes — NOT injected HTML — so // the persists across re-renders and the CSS drift animation keeps // running instead of restarting (which looked like the clouds shaking). const cloudDim = isDay ? 1 : 0.55; // moonlit clouds read dimmer at night function buildCloudLayer(cov, yBand, scale, durSec, seed, key) { // Always render the maximum 4 sprites so existing elements persist // in the DOM and their opacity can CSS-transition instead of blinking in/out. const maxCount = 4; const f = cov != null ? Math.min(1, cov / 100) : 0; const activeCount = (cov != null && cov >= 8) ? Math.round(1 + f * 3) : 0; const baseOp = Math.min(0.9, (0.22 + f * 0.6) * cloudDim); const w = 92 * scale, hh = 48 * scale; const imgs = []; for (let k = 0; k < maxCount; k++) { const px = (seed * 47 + k * (200 / maxCount)) % 200; const py = yBand + (((seed + k * 7) % 12) - 6); const spriteOp = k < activeCount ? baseOp : 0; [px, px + 200].forEach((X, j) => { imgs.push(html``); }); } return html`${imgs}`; } const cloudLayers = [ buildCloudLayer(ccHigh, 44, 0.7, 70, 3, 'ch'), buildCloudLayer(ccMid, 64, 0.9, 52, 7, 'cm'), buildCloudLayer(ccLow != null ? ccLow : ccTotal, 84, 1.15, 40, 11, 'cl'), ]; // Fog/haze wash when visibility drops (km). Drawn under the dial graphics. const fogAlpha = (visKm != null && visKm < 8) ? Math.min(0.5, (8 - visKm) / 8 * 0.5) : 0; const fogColor = isDay ? '225,228,232' : '120,128,140'; // --- Ground scene ----------------------------------------------------- // Per-environment ground: a multi-stop gradient (some split into bands like // beach sea/sand or a river's bank/water/bank) plus objects scattered with // ground-plane perspective (lower = nearer = bigger; far = small + hazy). // Every surface AND object flows through one colour-cycle model: darken by // elevation (day → night), tint toward the live horizon sky (golden hour / // overcast / storm), and lightly whiten land grounds when it snows. Drawn // UNDER the dial, with deeper objects pushed off-centre and a soft scrim // behind the readout to keep the numbers legible. const groundR = 95; // = lens radius const groundOpacity = +(0.96 * (1 - Math.min(0.7, fogAlpha * 1.2))).toFixed(2); const groundSnowy = snowRate > 0; // Per-mount random seed: each page load arranges the objects differently, // but it's fixed for the session so re-renders / playback never re-shuffle. const layoutSeedRef = useRef((Math.random() * 1e9) | 0); const layoutSeed = layoutSeedRef.current; const spec = GROUND_SPEC[env] || GROUND_SPEC.open; // Day → night darkening amount from solar elevation. const nightAmt = elev >= 6 ? 0 : elev <= -12 ? 0.85 : (6 - elev) / 18 * 0.85; // Runs one daytime base colour through the shared cycle. function groundTone(dayHex, allowSnow) { let c = blendHex(dayHex, spec.night, nightAmt); // day → night c = blendHex(c, skyBotTint, 0.16); // ambient sky tint c = blendHex(c, greyTarget, tintAmt * 0.32); // heavier weather grey if (allowSnow && spec.snowGround && groundSnowy) c = blendHex(c, '#eef2f6', Math.min(0.5, snowRate * 0.5)); return c; } const groundStops = spec.grad.map(([off, hex]) => ({ off: +(off * 100).toFixed(1), col: groundTone(hex, true) })); // Depth-shaded silhouette endpoints, tied to the live sky tone but kept // dark enough to read as silhouettes against bright ground/sky (near = very // dark, far = hazier but still clearly visible). const nearTone = blendHex(skyBotTint, '#0a0e16', 0.68); // close & dark const farTone = blendHex(skyBotTint, '#0a0e16', 0.42); // distant & hazy // Colours a scattered object by its material, then applies the cycle: // darken at night, tint toward the sky (ambient + distance haze), grey in // bad weather. Distant objects (low d) pick up more sky → recede/haze. function objTone(dayHex, d) { let c = blendHex(dayHex, '#0a0e16', nightAmt * 0.82); // darken toward night c = blendHex(c, skyBotTint, 0.12 + (1 - d) * 0.32); // ambient + distance haze c = blendHex(c, greyTarget, tintAmt * 0.3); // weather grey return c; } const snowTone = blendHex(skyBotTint, '#ffffff', 0.6); // snow caps (dim at night) // Readout scrim colour: light by day (dark text), dark by night (light text). const scrimColor = isDay ? '#f1e8d0' : '#0b0916'; function buildGround() { const cyB = 100; const out = []; let i = 0; const P = (d, f, o) => out.push(html``); const C = (x, y, r, f, o) => out.push(html``); // Shapes grow upward from their ground point (x, by) to height s. const pine = (x, by, s, f, o) => { const w = s * 0.4; P(`M ${x} ${by - s} L ${x + w * 0.5} ${by - s * 0.5} L ${x + w * 0.22} ${by - s * 0.5} L ${x + w} ${by} L ${x - w} ${by} L ${x - w * 0.22} ${by - s * 0.5} L ${x - w * 0.5} ${by - s * 0.5} Z`, f, o); if (groundSnowy) P(`M ${x} ${by - s} L ${x - w * 0.26} ${by - s * 0.72} L ${x + w * 0.26} ${by - s * 0.72} Z`, snowTone, o); }; const oak = (x, by, s, f, o) => { // broad rounded crown const tw = s * 0.08; P(`M ${x - tw} ${by} L ${x - tw * 0.7} ${by - s * 0.5} L ${x + tw * 0.7} ${by - s * 0.5} L ${x + tw} ${by} Z`, f, o); C(x, by - s * 0.66, s * 0.34, f, o); C(x - s * 0.27, by - s * 0.54, s * 0.26, f, o); C(x + s * 0.27, by - s * 0.54, s * 0.26, f, o); C(x - s * 0.12, by - s * 0.82, s * 0.24, f, o); C(x + s * 0.15, by - s * 0.8, s * 0.22, f, o); }; const bush = (x, by, s, f, o) => { C(x - s * 0.3, by - s * 0.24, s * 0.3, f, o); C(x + s * 0.28, by - s * 0.22, s * 0.26, f, o); C(x, by - s * 0.4, s * 0.36, f, o); }; const hedge = (x, by, s, f, o) => { // low wide rounded row const w = s * 1.3, h = s * 0.6; P(`M ${x - w} ${by} L ${x - w} ${by - h * 0.7} L ${x + w} ${by - h * 0.7} L ${x + w} ${by} Z`, f, o); C(x - w * 0.6, by - h * 0.7, h * 0.55, f, o); C(x, by - h * 0.85, h * 0.6, f, o); C(x + w * 0.6, by - h * 0.7, h * 0.55, f, o); }; const building = (x, by, s, f, o, wf) => { const w = s * (wf || 0.5); P(`M ${x - w / 2} ${by} L ${x - w / 2} ${by - s} L ${x + w / 2} ${by - s} L ${x + w / 2} ${by} Z`, f, o); }; const cactus = (x, by, s, f, o) => { const t = s * 0.11; P(`M ${x - t} ${by} L ${x - t} ${by - s} Q ${x - t} ${by - s - t} ${x} ${by - s - t} Q ${x + t} ${by - s - t} ${x + t} ${by - s} L ${x + t} ${by} Z`, f, o); P(`M ${x - t} ${by - s * 0.6} l ${-s * 0.22} 0 l 0 ${-s * 0.3} l ${s * 0.1} 0 l 0 ${s * 0.2} l ${s * 0.12} 0 Z`, f, o); P(`M ${x + t} ${by - s * 0.45} l ${s * 0.22} 0 l 0 ${-s * 0.3} l ${-s * 0.1} 0 l 0 ${s * 0.2} l ${-s * 0.12} 0 Z`, f, o); }; const palm = (x, by, s, f, o) => { P(`M ${x - s * 0.05} ${by} Q ${x - s * 0.18} ${by - s * 0.6} ${x - s * 0.11} ${by - s} L ${x} ${by - s} Q ${x - s * 0.07} ${by - s * 0.6} ${x + s * 0.05} ${by} Z`, f, o); const tx = x - s * 0.11, ty = by - s, fr = s * 0.45; P(`M ${tx} ${ty} L ${tx - fr} ${ty - fr * 0.28} L ${tx - fr * 0.7} ${ty + fr * 0.16} Z`, f, o); P(`M ${tx} ${ty} L ${tx + fr} ${ty - fr * 0.28} L ${tx + fr * 0.7} ${ty + fr * 0.16} Z`, f, o); P(`M ${tx} ${ty} L ${tx - fr * 0.3} ${ty - fr} L ${tx + fr * 0.3} ${ty - fr * 0.9} Z`, f, o); P(`M ${tx} ${ty} L ${tx + fr * 0.62} ${ty - fr * 0.8} L ${tx + fr * 0.86} ${ty - fr * 0.42} Z`, f, o); }; const parasol = (x, by, s, f, o) => { P(`M ${x - s * 0.04} ${by} L ${x - s * 0.04} ${by - s} L ${x + s * 0.04} ${by - s} L ${x + s * 0.04} ${by} Z`, f, o); P(`M ${x - s * 0.5} ${by - s} Q ${x} ${by - s - s * 0.42} ${x + s * 0.5} ${by - s} Z`, f, o); }; const reed = (x, by, s, f, o) => { for (let k = -1; k <= 1; k++) { const rx = x + k * s * 0.22, rh = s - Math.abs(k) * s * 0.28; P(`M ${rx - s * 0.04} ${by} L ${rx - s * 0.04} ${by - rh} L ${rx + s * 0.04} ${by - rh} L ${rx + s * 0.04} ${by} Z`, f, o); C(rx, by - rh - s * 0.08, s * 0.09, f, o); } }; const boat = (x, by, s, f, o) => { P(`M ${x - s * 0.45} ${by - s * 0.12} L ${x + s * 0.45} ${by - s * 0.12} L ${x + s * 0.3} ${by + s * 0.05} L ${x - s * 0.3} ${by + s * 0.05} Z`, f, o); P(`M ${x} ${by - s * 0.12} L ${x} ${by - s} L ${x + s * 0.4} ${by - s * 0.22} Z`, f, o); P(`M ${x} ${by - s * 0.12} L ${x - s * 0.05} ${by - s * 0.85} L ${x - s * 0.36} ${by - s * 0.18} Z`, f, o); }; const DRAW = { pine, oak, bush, hedge, building, cactus, palm, parasol, reed, boat }; // Alpine: a distant range of irregular, jagged peaks (not pyramids) — // off-centre summits with sub-shoulders, hazier the further back. if (spec.obj.mountains) { const capTone = blendHex(skyBotTint, '#ffffff', 0.72); // snow, cycle-lit const mtn = (x, w, h, seed, d) => { const r = seededRng(seed); const tone = objTone('#6a6258', d); // rocky; far = hazier // Summit can shift a little, but is clamped to stay between its // neighbour points so the ridge never backtracks into a hook. const leftN = [x - w * 0.12, cyB - h * (0.72 + 0.08 * r())]; const rightN = [x + w * 0.16, cyB - h * (0.62 + 0.16 * r())]; const peakX = Math.max(leftN[0] + w * 0.02, Math.min(rightN[0] - w * 0.02, x + (r() - 0.5) * w * 0.18)); const peak = [peakX, cyB - h]; const pts = [ [x - w, cyB], [x - w * 0.58, cyB - h * (0.30 + 0.22 * r())], [x - w * 0.30, cyB - h * (0.50 + 0.14 * r())], leftN, peak, rightN, [x + w * 0.42, cyB - h * (0.36 + 0.18 * r())], [x + w * 0.72, cyB - h * (0.16 + 0.12 * r())], [x + w, cyB], ]; P('M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ') + ' Z', tone, groundOpacity); // Snow cap — always white, covering the top ~half of the peak. Traced // down the real ridge edges (peak → shoulder → mid-slope) so it can't // spill outside the silhouette, with a jagged snow line across. const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; const leftDeep = pts[2], rightDeep = pts[6]; const lEdge = lerp(leftN, leftDeep, 0.55); // down the left slope const rEdge = lerp(rightN, rightDeep, 0.5); // down the right slope const baseY = (lEdge[1] + rEdge[1]) / 2; const j1 = [lerp(rEdge, lEdge, 0.34)[0], baseY - h * 0.05]; const j2 = [lerp(rEdge, lEdge, 0.66)[0], baseY + h * 0.035]; const fmt = p => p[0].toFixed(1) + ' ' + p[1].toFixed(1); P(`M ${fmt(peak)} L ${fmt(rightN)} L ${fmt(rEdge)} L ${fmt(j1)} L ${fmt(j2)} L ${fmt(lEdge)} L ${fmt(leftN)} Z`, capTone, groundOpacity); }; mtn(cx - 30, 52, 30, spec.obj.seed + layoutSeed + 1, 0.05); mtn(cx + 26, 46, 37, spec.obj.seed + layoutSeed + 2, 0.16); mtn(cx + 64, 34, 22, spec.obj.seed + layoutSeed + 3, 0.10); } // City: 3–4 receding skyline rows — back rows higher, hazier, lighter; // front rows lower, darker, with occasional landmarks + lit windows. if (spec.obj.skyline) { const rows = 4; for (let L = 0; L < rows; L++) { const depth = L / (rows - 1); // 0 back .. 1 front const by = cyB + (0.16 + depth * 0.5) * 86; const fill = blendHex(farTone, nearTone, 0.2 + depth * 0.72); const op = +(groundOpacity * (0.7 + depth * 0.3)).toFixed(2); const rr = seededRng(spec.obj.seed + layoutSeed + L * 101); let x = 4; while (x < 196) { const w = (5 + rr() * 9) * (0.6 + depth * 0.7); const cDist = Math.abs((x + w / 2) - cx) / 100; // 0 centre .. ~1 edge let h = (5 + rr() * 20) * (0.5 + depth) * (0.45 + 0.55 * cDist); h = Math.min(h, by - 104); if (h > 2) { building(x + w / 2, by, h, fill, op, w / h); const top = by - h, cxb = x + w / 2; if (depth > 0.55 && rr() < 0.22) { // landmark roof const r = rr(), spike = 5 * (0.6 + depth); if (r < 0.4) P(`M ${cxb - 0.6} ${top} L ${cxb - 0.6} ${top - spike} L ${cxb + 0.6} ${top - spike} L ${cxb + 0.6} ${top} Z`, fill, op); else if (r < 0.72) P(`M ${x + w * 0.3} ${top} Q ${cxb} ${top - w * 0.55} ${x + w * 0.7} ${top} Z`, fill, op); else P(`M ${x + w * 0.28} ${top} L ${x + w * 0.28} ${top - w * 0.4} L ${x + w * 0.72} ${top - w * 0.4} L ${x + w * 0.72} ${top} Z`, fill, op); } if (depth > 0.78 && h > 14 && w > 8) { // sparse lit windows const wf2 = blendHex(fill, skyBotTint, 0.3); for (let sx = x + 2; sx < x + w - 1.5; sx += 2.6) P(`M ${sx} ${by - 2} L ${sx} ${top + 2} L ${sx + 0.8} ${top + 2} L ${sx + 0.8} ${by - 2} Z`, wf2, op * 0.85); } } x += w + rr() * 2; } } } // Scattered objects with ground-plane perspective, constrained to the // env's allowed depth bands (e.g. beach sand, river banks). if (spec.obj.types) { const cfg = spec.obj; const rnd = seededRng(cfg.seed + layoutSeed); const bands = cfg.bands || [[0.12, 0.92]]; const objs = []; for (let k = 0; k < cfg.n; k++) { const band = bands[k % bands.length]; // Stratify horizontally so objects spread evenly across the width // (with jitter) instead of clustering; deeper objects sit lower. const slot = (k + 0.5) / cfg.n; // 0..1 even const jitter = (rnd() - 0.5) * (1.4 / cfg.n); objs.push({ d: band[0] + rnd() * (band[1] - band[0]), xf: Math.max(-1, Math.min(1, (slot * 2 - 1) + jitter)), type: cfg.types[Math.floor(rnd() * cfg.types.length)], wf: 0.4 + rnd() * 0.5, }); } objs.sort((a, b) => a.d - b.d); // far → near for (const o of objs) { const by = cyB + o.d * 86; // lower = nearer const hw = Math.sqrt(Math.max(0, groundR * groundR - (by - cyB) * (by - cyB))); // Mild nudge off the readout column (the scrim handles legibility). let xf = o.xf; if (by > 118) { const av = 0.18 + o.d * 0.18; xf = (xf < 0 ? -1 : 1) * (av + (1 - av) * Math.abs(xf)); } const x = cx + xf * hw * 0.9; const s = cfg.base * (0.4 + o.d * (cfg.sizeMul || 1.25)); // nearer = bigger const fill = objTone(MATERIAL[o.type] || '#4f7d39', o.d); // material colour + cycle const op = +(groundOpacity * (1 - Math.min(0.6, fogAlpha * (1.5 - o.d)))).toFixed(2); (DRAW[o.type] || oak)(x, by, s, fill, op, o.wf); } } return out; } const groundShapes = buildGround(); 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'; // --- Precip FX canvas -------------------------------------------------- // Animated rain/snow/lightning composited over the lens glass. The rAF // loop reads live values from fxRef so it never restarts on re-render. const canvasRef = useRef(null); const fxRef = useRef({}); fxRef.current = { rain: rainRate, snow: snowRate, storm: !!stormy, heavy, wind: windSpeed, gust: gustSpeed, windH }; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); let raf, w = 0, hgt = 0; const drops = [], flakes = [], winds = []; let flash = 0, gustPulse = 0; function resize() { const dpr = window.devicePixelRatio || 1; w = canvas.clientWidth || 200; hgt = canvas.clientHeight || 200; canvas.width = Math.round(w * dpr); canvas.height = Math.round(hgt * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } resize(); const ro = new ResizeObserver(resize); ro.observe(canvas); function frame() { const fx = fxRef.current; ctx.clearRect(0, 0, w, hgt); // Wind — base speed with random gust pulses ramping up to gust speed. if (fx.wind > 3 && Math.random() < 0.006 + fx.wind * 0.0006) gustPulse = 1; gustPulse = Math.max(0, gustPulse - 0.012); const effWind = fx.wind + gustPulse * Math.max(0, fx.gust - fx.wind); const hv = fx.windH * effWind * 0.22; // horizontal px/frame // Wind particles — faint horizontal streaks drifting across the lens, // stretching and brightening during gusts. const windTarget = fx.wind > 1.5 ? Math.min(4, Math.round(fx.wind * 0.35)) : 0; while (winds.length < windTarget) winds.push({ x: Math.random() * w, y: Math.random() * hgt, r: 1.4 + Math.random() * 1.2, spd: 0.7 + Math.random() * 0.6, ph: Math.random() * 6.28 }); if (winds.length > windTarget) winds.length = windTarget; if (winds.length) { ctx.fillStyle = 'rgba(236,243,251,' + (0.22 + gustPulse * 0.22).toFixed(3) + ')'; for (const p of winds) { const vx = hv * p.spd; const rad = p.r * (1 + gustPulse * 0.4); const m = rad + 2; p.x += vx; p.ph += 0.05; p.y += Math.sin(p.ph) * 0.5; // drift across with a gentle bob if (vx >= 0 && p.x > w + m) { p.x = -m; p.y = Math.random() * hgt; } else if (vx < 0 && p.x < -m) { p.x = w + m; p.y = Math.random() * hgt; } else if (p.y > hgt + m) { p.y = -m; } else if (p.y < -m) { p.y = hgt + m; } ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, 7); ctx.fill(); } } // Rain — count scales with mm/h; wind adds horizontal weight so the // streaks lean and travel across in the wind direction. const rainTarget = Math.min(90, Math.round(fx.rain * 13)); while (drops.length < rainTarget) drops.push({ x: Math.random() * w, y: Math.random() * hgt, l: 8 + Math.random() * 10, s: 4.5 + Math.random() * 3.5 }); if (drops.length > rainTarget) drops.length = rainTarget; if (drops.length) { const lean = hv * 1.6 + (fx.heavy ? 1 : 0.6); ctx.strokeStyle = 'rgba(180,205,232,0.5)'; ctx.lineWidth = 1; ctx.lineCap = 'round'; ctx.beginPath(); for (const d of drops) { ctx.moveTo(d.x, d.y); ctx.lineTo(d.x - lean, d.y - d.l); d.y += d.s; d.x += hv * 0.9; if (d.y > hgt + 10) { d.y = -10; d.x = Math.random() * w; } else if (d.x < -12) { d.x = w + 10; d.y = Math.random() * hgt * 0.7; } else if (d.x > w + 12) { d.x = -10; d.y = Math.random() * hgt * 0.7; } } ctx.stroke(); } // Snow — drifting flakes; wind carries them across too. const snowTarget = Math.min(70, Math.round(fx.snow * 45)); while (flakes.length < snowTarget) flakes.push({ x: Math.random() * w, y: Math.random() * hgt, r: 1 + Math.random() * 1.8, vy: 0.5 + Math.random() * 0.9, p: Math.random() * 6.28 }); if (flakes.length > snowTarget) flakes.length = snowTarget; if (flakes.length) { ctx.fillStyle = 'rgba(255,255,255,0.85)'; for (const f of flakes) { f.y += f.vy; f.p += 0.02; f.x += Math.sin(f.p) * 0.4 + hv * 0.6; if (f.y > hgt + 5) { f.y = -5; f.x = Math.random() * w; } else if (f.x < -6) { f.x = w + 5; } else if (f.x > w + 6) { f.x = -5; } ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill(); } } // Lightning — occasional flash during storms/heavy precip. if (fx.storm && Math.random() < 0.004) flash = 1; if (flash > 0) { ctx.fillStyle = 'rgba(230,238,255,' + (flash * 0.45).toFixed(3) + ')'; ctx.fillRect(0, 0, w, hgt); flash -= 0.08; } raf = requestAnimationFrame(frame); } raf = requestAnimationFrame(frame); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, []); return html`
${groundStops.map((s, i) => html``)} ${isDay && html`<${Fragment}> `} ${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}> `} ${groundShapes} ${cloudLayers} ${(() => { const overlaySVG = getLensOverlaySVG(activeEvent, cx, cy, lensR); if (!overlaySVG) return null; return html``; })()} ${gradArc.map((seg, 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)}° SUNSOAK INDEX ${cat.label.toUpperCase()} ${utciEnvShort && html` <${Fragment}> ${utciEnvShort.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) )} ` }