// ------------------------------------------------------------------------
//  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, useLayoutEffect, 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: 30, n: 14, sizeMul: 1.7, seed: 3031, bands: [[0.04, 0.94]], randomX: true } },
  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.10, '#3f5a28'], [0.15, '#42738b'], [0.44, '#356074'], [0.50, '#4d6c30'], [1, '#345020']],
               obj: { types: ['reed', 'oak'], base: 17, n: 8, sizeMul: 1.3, seed: 5053, bands: [[0.03, 0.09], [0.58, 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: '#0e2208', pine: '#091410', bush: '#1e380e', hedge: '#122808',
  cactus: '#1e4016', palm: '#102a10', parasol: '#bb5a46', reed: '#161e04',
  boat: '#ccc4ad',
};

const MOON_TEX = 'data:image/png;base64,[encoded data, 34816 characters]';

// -------------------------------------------------------------------
// 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
// -------------------------------------------------------------------
// Finds the nearest scrollable ancestor (e.g. the profile/config bottom
// sheet's scrolling body) so dropdown panels can size themselves to the
// space actually available before that ancestor clips them, rather than
// guessing against the full viewport and getting cropped invisibly.
function getScrollParent(el) {
  let node = el && el.parentElement;
  while (node && node !== document.body) {
    const style = getComputedStyle(node);
    if (/(auto|scroll)/.test(style.overflowY)) return node;
    node = node.parentElement;
  }
  return document.documentElement;
}

export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild, grpClass, sceneStyle, sceneContent }) {
  const [open, setOpen] = useState(false);
  const [flipUp, setFlipUp] = useState(false);
  const [maxPanelHeight, setMaxPanelHeight] = useState(null);
  const [panelLeft, setPanelLeft] = useState(null);
  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, centre it under/over the button, then clamp
  // that position so it never spills past the real clipping ancestor's
  // edges (e.g. the profile/config bottom sheet's scrolling body, which
  // also clips horizontally whenever it clips vertically — see
  // getScrollParent above — so comparing against the browser viewport alone
  // isn't enough). Also flip to whichever side (up/down) has more room, and
  // cap the panel's height to whatever space is actually available on that
  // side so a long list scrolls internally instead of being cropped
  // invisibly.
  // useLayoutEffect (not useEffect) so this measure-and-position happens
  // before the browser paints the first frame — otherwise the panel visibly
  // flashes in its unclamped spot for one frame before correcting itself.
  useLayoutEffect(() => {
    if (!open) { setFlipUp(false); setMaxPanelHeight(null); setPanelLeft(null); return; }
    if (!panelRef.current || !wrapRef.current) return;
    const rect     = panelRef.current.getBoundingClientRect();
    const wrapRect = wrapRef.current.getBoundingClientRect();
    const bound    = getScrollParent(wrapRef.current).getBoundingClientRect();

    const desiredLeft = wrapRect.left + wrapRect.width / 2 - rect.width / 2;
    const minLeft = bound.left + 4;
    const maxLeft = bound.right - 4 - rect.width;
    const clampedLeft = Math.max(minLeft, Math.min(desiredLeft, maxLeft));
    setPanelLeft(clampedLeft - wrapRect.left);

    const spaceBelow = bound.bottom - wrapRect.bottom;
    const spaceAbove = wrapRect.top - bound.top;
    const overflowsDown = rect.bottom > bound.bottom - 8;
    const flip = overflowsDown && spaceAbove > spaceBelow;
    setFlipUp(flip);
    setMaxPanelHeight(Math.max(80, Math.floor((flip ? spaceAbove : spaceBelow) - 12)));
  }, [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(' ');

  // Longer lists (SunSoak, Indoors, ...) lay out as two columns so every
  // option is visible at a glance instead of relying on people to notice
  // they need to scroll a cropped list.
  const useColumns = options.length > 5;

  return html`
    <span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}>
      <button
        class=${btnClass}
        onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}
        type="button"
      >
        ${sceneStyle !== undefined && html`<span class="cs-btn-scene" style=${sceneStyle}>${sceneContent || ''}</span>`}
        <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${flipUp ? ' cs-panel--up' : ''}${useColumns ? ' cs-panel--cols' : ''}`} ref=${panelRef} role="listbox"
          style=${{
            ...(panelLeft != null ? { left: panelLeft + 'px' } : {}),
            ...(maxPanelHeight != null ? { maxHeight: maxPanelHeight + 'px' } : {}),
          }}>
          ${isOn && !noHide && html`
            <span
              class="cs-option"
              role="option"
              onClick=${() => { onChange('off'); setOpen(false); }}
            >${hidingLabel || ('Hide ' + hideLabel)}</span>
            <div class="cs-divider"></div>
          `}
          ${options.map(opt => opt.divider
            ? html`<div key=${opt.value} class="cs-divider"></div>`
            : html`<span
              key=${opt.value}
              class=${`cs-option${opt.value === value && isOn ? ' selected' : ''}${opt.disabled ? ' disabled' : ''}`}
              role="option"
              onClick=${() => {
                if (opt.disabled) return;
                onChange(opt.value);
                setOpen(false);
              }}
            >${opt.label}</span>`
          )}
        </div>
      `}
    </span>`;
}

// -------------------------------------------------------------------
// VENTPILL - the checkbox pill fused to the right of a CustomSelect.
// -------------------------------------------------------------------
//  props:
//    checked  - bool
//    onChange - fn()
//    label    - string
//    title    - tooltip string
// -------------------------------------------------------------------
export function VentPill({ checked, onChange, label, title, grpClass }) {
  return html`
    <label class=${[`cs-vent`, grpClass || '', checked ? 'on' : ''].filter(Boolean).join(' ')} title=${title}>
      <input
        type="checkbox"
        checked=${checked}
        onChange=${onChange}
        style=${{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
      />
      <span class=${`cs-checkbox${checked ? ' checked' : ''}`}></span>
      ${label}
    </label>`;
}

// -------------------------------------------------------------------
// SKYSCOPE - the little porthole circle next to each hour.
// -------------------------------------------------------------------
// What gets drawn (back to front):
//   1. Sky disk        - colour from skyFillForElev() above
//   2. Horizon line    - faint dashed line at the middle
//   3. Sun OR moon     - sun positioned vertically by elevation;
//                        moon shows real phase (synodic period)
//   4. Brass ring      - the telescope/scope edge (#c8922a)
//   5. Lens highlight  - subtle inner glint
//
// Tweakable bits inside this function:
//   - size       - pass a different size= when calling for bigger/smaller
//   - innerR     - how thick the brass ring looks
//   - sunR       - the sun's drawn radius
//   - the stroke colour #c8922a is the brass - change for a different metal
// -------------------------------------------------------------------
export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
  const r = size / 2;
  const innerR = r - 1.2;

  // Rising if local hour < 12 (dt.getUTCHours() == local hour after timezone fix)
  const localHour = dt ? dt.getUTCHours() : 12;
  const isRising = localHour < 12;

  const skyGrad = skyGradientForElev(elev, isRising);

  const isDay   = elev > 0;    // sun only drawn when actually above horizon
  const isTwil  = elev > -8 && !isDay;  // moon shown in twilight too
  const isNight = elev <= -8;

  // Sun position - clamped so it never goes below the horizon line (y = r)
  const elevClamped = Math.max(0, Math.min(90, elev));
  const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
  const sunR = Math.max(2.2, innerR * 0.32);

  // Radial glow: strength driven by glob (W/m-). Uses a radialGradient
  // so it's intense at the sun centre and fades smoothly to transparent.
  const radFrac   = Math.min(1, (glob || 0) / 900); // 0 (dark) - 1 (blazing)
  const horizFrac = Math.max(0, 1 - elev / 20);     // extra warmth near horizon
  const glowR     = sunR + 4 + radFrac * 6;          // total glow radius
  const glowPeak  = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity

  const phase = moonPhaseFraction(dt);
  const moonR = innerR * 0.55;
  const shadowOffset = (1 - Math.abs(2 * phase - 1)) * 2 * moonR;
  const shadowCx = r + (phase < 0.5 ? -shadowOffset : shadowOffset);
  const grass = grassFillForElev(elev);

  const uid    = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}`;
  const clipId = `clip-${uid}`;
  const skyId  = `sky-${uid}`;
  const grassId = `grass-${uid}`;

  return html`
    <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
         style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
      <defs>
        <clipPath id=${clipId}>
          <circle cx=${r} cy=${r} r=${innerR} />
        </clipPath>
        <!-- Sky gradient — top of disk to bottom (zenith → horizon) -->
        <linearGradient id=${skyId} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   stop-color=${skyGrad.top} />
          <stop offset="100%" stop-color=${skyGrad.bot} />
        </linearGradient>
        <!-- Grass gradient — horizon to ground -->
        <linearGradient id=${grassId} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   stop-color=${grass.top} />
          <stop offset="100%" stop-color=${grass.bot} />
        </linearGradient>
        <!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
        <radialGradient id=${`glow-${uid}`} gradientUnits="userSpaceOnUse"
                        cx=${r} cy=${sunY} r=${glowR}>
          <stop offset="0%"   stop-color="#fff8c0" stop-opacity="1" />
          <stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
          <stop offset="70%"  stop-color="#ffe060" stop-opacity=${glowPeak * 0.5} />
          <stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
        </radialGradient>
        <!-- Moon phase mask: lit area = white, shadow = black punched out -->
        <mask id=${`moon-mask-${uid}`}>
          <circle cx=${r} cy=${r} r=${moonR} fill="white" />
          <circle cx=${shadowCx} cy=${r} r=${moonR} fill="black" />
        </mask>
        <!-- Moon texture pattern - sized to fit the moon disk -->
        <pattern id=${`moon-tex-${uid}`} x=${r - moonR - 2} y=${r - moonR - 2}
                 width=${moonR * 2 + 4} height=${moonR * 2 + 4} patternUnits="userSpaceOnUse">
          <image href=${MOON_TEX} x="0" y="0" width=${moonR * 2 + 4} height=${moonR * 2 + 4} />
        </pattern>
      </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="#e7e3d4" mask=${`url(#moon-mask-${uid})`} />
          <!-- Texture overlay on lit face -->
          <circle cx=${r} cy=${r} r=${moonR}
                  fill=${`url(#moon-tex-${uid})`}
                  mask=${`url(#moon-mask-${uid})`}
                  opacity="0.55" />
          <!-- Dim unlit face so the full disk outline remains visible -->
          <circle cx=${r} cy=${r} r=${moonR}
                  fill="rgba(200,192,230,0.08)" stroke="none" />
        </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.
// The time-cell SkyScope owns full day/night imagery; this stays a
// simple glyph. Exception: 'wispy'/'scattered' in daylight draw a
// small sun peeking from behind the cloud, so a lightly-clouded day
// still reads as "mostly sunny" rather than looking fully overcast.
// -------------------------------------------------------------------
//   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(), filled = false, showSun = true }) {
  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' && elev >= 0 && showSun && html`
        ${sun(size * 0.50, size * 0.38, size * 0.16)}`}
      ${category === 'clear' && elev < 0 && html`
        <path d=${`M ${size*0.45} ${size*0.15}
                   A ${size*0.20} ${size*0.20} 0 1 1 ${size*0.45} ${size*0.55}
                   A ${size*0.235} ${size*0.235} 0 1 0 ${size*0.45} ${size*0.15} Z`}
              fill=${brass} opacity="0.85" />
        <circle cx=${size*0.82} cy=${size*0.22} r=${size*0.030} fill=${brass} opacity="0.75" />
        <circle cx=${size*0.20} cy=${size*0.28} r=${size*0.022} fill=${brass} opacity="0.65" />
        <circle cx=${size*0.75} cy=${size*0.60} r=${size*0.025} fill=${brass} opacity="0.70" />
        <circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" />
        <circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`}
      ${category === 'wispy' && elev >= 0 && html`
        ${showSun && sun(size * 0.66, size * 0.30, size * 0.13)}
        ${line(size * 0.14, size * 0.58, size * 0.64, size * 0.58, brass, sw * 0.85, 0.85)}
        ${line(size * 0.24, size * 0.74, size * 0.78, size * 0.74, ink, sw * 0.78, 0.68)}`}
      ${category === 'wispy' && elev < 0 && 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' && elev >= 0 && html`
        ${showSun && sun(size * 0.72, size * 0.24, size * 0.11)}
        <path d=${cloudPath(-size * 0.20, -size * 0.32, 0.36)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
              stroke-width=${sw * 0.65} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
        <path d=${cloudPath(size * 0.18, -size * 0.38, 0.30)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
              stroke-width=${sw * 0.6} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
        <path d=${cloudPath(0, -size * 0.20, 0.28)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
              stroke-width=${sw * 0.55} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />`}
      ${category === 'scattered' && elev < 0 && html`
        <path d=${cloudPath(0, -size * 0.2425, 0.82)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink}
              stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
        ${!filled && 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=${filled ? 'rgba(220,230,240,0.72)' : 'none'} stroke=${filled ? 'none' : 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=${filled ? 'rgba(255,255,255,0.92)' : 'none'} stroke=${filled ? 'none' : 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,
                               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 <g> 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 <image> 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`<image key=${k + '-' + j} href=${CLOUD_IMG}
          x=${(X - w / 2).toFixed(1)} y=${(py - hh / 2).toFixed(1)}
          width=${w.toFixed(1)} height=${hh.toFixed(1)} opacity=${spriteOp.toFixed(2)}
          style="transition:opacity 2s ease"
          preserveAspectRatio="xMidYMid meet" />`);
      });
    }
    return html`<g key=${key} class="scope-cloud-layer"
                   style=${`--drift:-200px;animation-duration:${durSec}s`}>${imgs}</g>`;
  }
  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', isDay ? 0.6 : isTwil ? 0.38 : 0.20);
  // 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`<path key=${'g' + (i++)} d=${d} fill=${f} opacity=${o} />`);
    const C = (x, y, r, f, o) => out.push(html`<circle key=${'g' + (i++)} cx=${x} cy=${y} r=${r} fill=${f} opacity=${o} />`);

    // 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', isDay ? 0.72 : isTwil ? 0.46 : 0.24);
      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.
        // randomX bypasses stratification for fully organic placement (e.g. forest).
        const slot = (k + 0.5) / cfg.n;                          // 0..1 even
        const jitter = (rnd() - 0.5) * (1.4 / cfg.n);
        const xf = cfg.randomX ? (rnd() * 2 - 1) : Math.max(-1, Math.min(1, (slot * 2 - 1) + jitter));
        objs.push({
          d: band[0] + rnd() * (band[1] - band[0]),
          xf,
          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.
      // gustPulse snaps to 1 on trigger then decays quickly so gusts feel punchy.
      if (fx.wind > 3 && Math.random() < 0.005 + fx.wind * 0.001) gustPulse = 1;
      gustPulse = Math.max(0, gustPulse - 0.022);
      const effWind = fx.wind + gustPulse * Math.max(0, fx.gust - fx.wind);
      const hv = fx.windH * effWind * 0.5;             // 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(6, Math.round(fx.wind * 0.45)) : 0;
      while (winds.length < windTarget) winds.push({ x: Math.random() * w, y: Math.random() * hgt, r: 1.6 + Math.random() * 1.4, 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`
    <div class="scope-wrap">
    <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=${skyTopTint} style="transition:stop-color 2s ease" />
          <stop offset="100%" stop-color=${skyBotTint} style="transition:stop-color 2s ease" />
        </linearGradient>
        <linearGradient id="scope-lens-grass" x1="0" y1="0" x2="0" y2="1">
          ${groundStops.map((s, i) => html`<stop key=${i} offset=${s.off + '%'} stop-color=${s.col} style="transition:stop-color 2s ease" />`)}
        </linearGradient>
        <!-- Soft scrim behind the readout so the numbers stay legible over busy ground -->
        <radialGradient id="scope-readout-scrim" gradientUnits="userSpaceOnUse" cx=${cx} cy="142" r="50">
          <stop offset="0%"   stop-color=${scrimColor} stop-opacity="0.3" />
          <stop offset="60%"  stop-color=${scrimColor} stop-opacity="0.2" />
          <stop offset="100%" stop-color=${scrimColor} stop-opacity="0" />
        </radialGradient>
        <!-- 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>
        <!-- Moon texture pattern - sized to fit the landscape moon disk -->
        <pattern id="scope-moon-tex" x=${cx - moonDrawR - 2} y=${moonY - moonDrawR - 2}
                 width=${moonDrawR * 2 + 4} height=${moonDrawR * 2 + 4} patternUnits="userSpaceOnUse">
          <image href=${MOON_TEX} x="0" y="0" width=${moonDrawR * 2 + 4} height=${moonDrawR * 2 + 4} />
        </pattern>
      </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="#e7e3d4" mask="url(#scope-moon-mask)" />
            <!-- Texture overlay on lit face -->
            <circle cx=${cx} cy=${moonY} r=${moonDrawR}
                    fill="url(#scope-moon-tex)"
                    mask="url(#scope-moon-mask)"
                    opacity="0.55" />
            <!-- Dim unlit face so the full disk outline is still visible -->
            <circle cx=${cx} cy=${moonY} r=${moonDrawR}
                    fill="rgba(200,192,230,0.08)" stroke="none" />
          </>`}
        ${groundShapes}
        <circle cx=${cx} cy="142" r="50" fill="url(#scope-readout-scrim)" />
        <g key="scope-wx">
          ${cloudLayers}
          <rect x="0" y="0" width="200" height="200" fill=${`rgb(${fogColor})`}
                opacity=${fogAlpha.toFixed(2)} style="transition:opacity 2s ease" />
        </g>
      </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>
    <canvas ref=${canvasRef} class="scope-fx" aria-hidden="true"></canvas>
    </div>`;
}

// -------------------------------------------------------------------
// 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, filled = false }) {
  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=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : 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>`

}

// HOUSEICON / CARICON - Brass Line glyphs for the day-tab shade when a
// modelled indoor or vehicle-cabin temperature stands in for the outdoor
// weather icon.
// -------------------------------------------------------------------
export function HouseIcon({ size = 30 }) {
  const sw = Math.max(1.25, size * 0.058);
  const brass = '#c8922a';
  const ink = '#2a1a08';
  return html`
    <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
         style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
      <path d=${`M ${size*0.13} ${size*0.52}
                 L ${size*0.50} ${size*0.15}
                 L ${size*0.87} ${size*0.52}`}
            fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
      <path d=${`M ${size*0.22} ${size*0.46}
                 V ${size*0.85}
                 H ${size*0.78}
                 V ${size*0.46}`}
            fill="none" stroke=${ink} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
      <path d=${`M ${size*0.42} ${size*0.85}
                 V ${size*0.60}
                 H ${size*0.58}
                 V ${size*0.85}`}
            fill="none" stroke=${ink} stroke-width=${sw*0.8} stroke-linecap="round" stroke-linejoin="round" />
    </svg>`;
}

// Body path is Lucide's "car" icon (ISC licence), used verbatim on its
// native 24x24 grid rather than hand-plotted, since freehand coordinates
// kept coming out lopsided.
export function CarIcon({ size = 30 }) {
  const brass = '#c8922a';
  const ink = '#2a1a08';
  return html`
    <svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
         style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
      <path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"
            stroke=${brass} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
      <path d="M9 17h6" stroke=${ink} stroke-width="1.8" stroke-linecap="round" />
      <circle cx="7" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
      <circle cx="17" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
    </svg>`;
}

// FOGICON - reuses the same closed puffy-cloud outline as PrecipIcon's
// cloud (rather than a foreign icon-set shape) so it sits consistently
// alongside the rain/snow glyphs, with mist bands underneath.
export function FogIcon({ size = 30 }) {
  const brass = '#c8922a';
  const ink = '#2a1a08';
  const sw = Math.max(1.25, size * 0.055);
  return html`
    <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
         style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
      <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=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
      <line x1=${size*0.14} y1=${size*0.46} x2=${size*0.86} y2=${size*0.46}
            stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
      <line x1=${size*0.22} y1=${size*0.58} x2=${size*0.78} y2=${size*0.58}
            stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
      <line x1=${size*0.10} y1=${size*0.70} x2=${size*0.90} y2=${size*0.70}
            stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" opacity="0.7" />
    </svg>`;
}

export function IceIcon({ size = 30 }) {
  const icy = '#3f73c4';
  return html`
    <svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
         style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
      <g stroke=${icy} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
        <path d="m10 20-1.25-2.5L6 18" />
        <path d="M10 4 8.75 6.5 6 6" />
        <path d="m14 20 1.25-2.5L18 18" />
        <path d="m14 4 1.25 2.5L18 6" />
        <path d="m17 21-3-6h-4" />
        <path d="m17 3-3 6 1.5 3" />
        <path d="M2 12h6.5L10 9" />
        <path d="m20 10-1.5 2 1.5 2" />
        <path d="M22 12h-6.5L14 15" />
        <path d="m4 10 1.5 2L4 14" />
        <path d="m7 21 3-6-1.5-3" />
        <path d="m7 3 3 6h4" />
      </g>
    </svg>`;
}
