// ════════════════════════════════════════════════════════════════════════
// components.js — Preact UI components (SVG widgets).
//
// Exports:
// SkyScope({ elev, dt, size }) little porthole circle per hour
// WindVane({ bearing, size }) compass arrow
// CloudIcon({ category, size, elev, dt }) cloud/sun/moon icon
// ScopeReticle({ value, cat, loading, elev, dt }) big UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon for precip cell
//
// All components use the html`` tagged template from htm + Preact.
// They depend on utils.js (skyFillForElev, grassFillForElev,
// moonPhaseFraction) but have no other external state.
// ════════════════════════════════════════════════════════════════════════
import { h, Fragment } from '../vendor/preact.js';
import htm from '../vendor/htm.js';
import {
skyGradientForElev, skyFillForElev, grassFillForElev,
moonPhaseFraction,
} from './utils.js';
const html = htm.bind(h);
// ═══════════════════════════════════════════════════════════════════
// 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, size = 26 }) {
const r = size / 2;
const innerR = r - 1.2;
// Rising if local hour < 12 (dt.getUTCHours() == local hour after timezone fix)
const localHour = dt ? dt.getUTCHours() : 12;
const isRising = localHour < 12;
const skyGrad = skyGradientForElev(elev, isRising);
// Flat horizon colour used for moon shadow fill
const skyHorizon = skyGrad.bot;
const isDay = elev > 0; // sun only drawn when actually above horizon
const isTwil = elev > -8 && !isDay; // moon shown in twilight too
const isNight = elev <= -8;
// Sun position — clamped so it never goes below the horizon line (y = r)
const elevClamped = Math.max(0, Math.min(90, elev));
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
const sunR = Math.max(2.2, innerR * 0.32);
// Near-horizon sun gets a warm glow halo
const sunGlowOpacity = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.65 : 0.25;
const sunGlowR = sunR + (elev < 6 ? 3.5 : 1.8);
const sunGlowColor = isRising ? '#ff8c3a' : '#ff9a6a';
const phase = moonPhaseFraction(dt);
const moonR = innerR * 0.55;
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
const moonLitFromRight = phase < 0.5;
const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset);
const grass = grassFillForElev(elev);
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}`;
const clipId = `clip-${uid}`;
const skyId = `sky-${uid}`;
const grassId = `grass-${uid}`;
return html`
`;
}
// ═══════════════════════════════════════════════════════════════════
// 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`
`;
}
// ═══════════════════════════════════════════════════════════════════
// CLOUDICON — clouds + sun/moon on a fully transparent background.
// No rim, no disc. Day/night aware: when elev < 0, the sun is
// replaced with a phased moon (same logic as SkyScope).
// ───────────────────────────────────────────────────────────────────
// category: 'clear' | 'wispy' | 'scattered' | 'overcast'
// elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase
// ═══════════════════════════════════════════════════════════════════
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
const r = size / 2;
const u = r; // half-extent for layout
const isNight = elev < -3;
// Phased moon helper — returns an SVG with the moon + shadow.
// Lit fraction direction matches SkyScope (waxing = lit from right).
const renderMoon = (cx, cy, moonR) => {
const phase = moonPhaseFraction(dt);
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
const moonLitFromRight = phase < 0.5;
const shadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
const clipId = `cmoon-${size}-${Math.round(cx)}-${Math.round(cy)}-${Math.round(phase * 1000)}`;
return html`
`;
};
return html`
`;
}
// ═══════════════════════════════════════════════════════════════════
// 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() }) {
// ── Day/night scene behind the readout ─────────────────────────────
// The whole back of the lens is a SkyScope-style scene: sky on top,
// grass on the bottom, sun positioned by elevation (or moon at night
// with its phase shadow). Mirrors the little hourly SkyScopes.
const cx = 100, cy = 100, R = 86;
const localHour = dt ? dt.getUTCHours() : 12;
const isRising = localHour < 12;
const isDay = elev > 0; // sun only above the actual horizon
const isTwil = elev > -8 && !isDay;
const skyGrad = skyGradientForElev(elev, isRising);
const skyHorizon = skyGrad.bot;
const grass = grassFillForElev(elev);
const phase = moonPhaseFraction(dt);
const lensR = 95;
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
const sunDrawR = 14;
// Near-horizon glow for the big dial
const sunGlowR = sunDrawR + (elev < 10 ? 7 : 3);
const sunGlowOp = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.55 : 0.2;
const sunGlowCol = isRising ? '#ff8c3a' : '#ff9a6a';
const moonDrawR = 15;
const moonY = cy - 45;
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR;
const moonLitFromRight = phase < 0.5;
const moonShadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
// Readout text colours flip with day/night for legibility against the sky.
const readoutColor = isDay ? '#1e1208' : '#f5edd6';
const readoutMutedColor = isDay ? '#9a7d5a' : '#d4c5a8';
const ticks = Array.from({ length: 36 }, (_, i) => {
const deg = i * 10 - 90;
const rad = (deg * Math.PI) / 180;
const major = i % 9 === 0;
const medium = i % 3 === 0;
const r2 = major ? R - 14 : medium ? R - 8 : R - 4;
return {
x1: cx + R * Math.cos(rad), y1: cy + R * Math.sin(rad),
x2: cx + r2 * Math.cos(rad), y2: cy + r2 * Math.sin(rad),
major, medium,
};
});
const stressBands = [
{ min: -40, max: -27, color: '#23408f' },
{ min: -27, max: -13, color: '#3f73c4' },
{ min: -13, max: 0, color: '#7eb0e0' },
{ min: 0, max: 9, color: '#bcd9ec' },
{ min: 9, max: 18, color: '#c8dcc0' },
{ min: 18, max: 26, color: '#6ab05a' },
{ min: 26, max: 32, color: '#e8c547' },
{ min: 32, max: 38, color: '#dc8a3a' },
{ min: 38, max: 46, color: '#c44a3a' },
{ min: 46, max: 50, color: '#7a1a1a' },
];
function fracToXY(frac, r) {
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
function bandArcPath(band) {
const f1 = Math.min(1, Math.max(0, (band.min + 10) / 60));
const f2 = Math.min(1, Math.max(0, (band.max + 10) / 60));
const arcR = R - 18;
const [x1, y1] = fracToXY(f1, arcR);
const [x2, y2] = fracToXY(f2, arcR);
const large = (f2 - f1) * 270 > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${arcR} ${arcR} 0 ${large} 1 ${x2} ${y2}`;
}
let needleX = cx, needleY = cy + 54;
if (value != null) {
const frac = Math.min(1, Math.max(0, (value + 10) / 60));
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
needleX = cx + 54 * Math.cos(rad);
needleY = cy + 54 * Math.sin(rad);
}
const glowColor = cat ? cat.bg : '#c8922a';
return html`
`;
}
// ═══════════════════════════════════════════════════════════════════
// PRECIPICON — rain drop or snowflake SVG for the precip cell.
// ───────────────────────────────────────────────────────────────────
// precip — mm/h rainfall
// snow — cm/h snowfall
// size — pixel size (default 28 to match CloudIcon)
//
// Intensity bands:
// dry → faint dash
// light rain (< 1 mm/h) → 1 drop
// moderate rain(< 4 mm/h) → 2 drops
// heavy rain (≥ 4 mm/h) → 3 drops
// light snow (< 0.5cm/h) → 1 flake
// heavy snow (≥ 0.5cm/h) → 2 flakes
// mixed → 1 drop + 1 flake
// ═══════════════════════════════════════════════════════════════════
export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
const r = size / 2;
const hasRain = precip > 0;
const hasSnow = snow > 0;
const mixed = hasRain && hasSnow;
// Drop shape: teardrop path centred on (cx, cy), radius dr
const drop = (cx, cy, dr) => {
const tip = cy + dr * 1.55;
const cw = dr * 0.72;
return `M ${cx} ${tip} C ${cx - cw} ${cy + dr * 0.6}, ${cx - dr} ${cy - dr * 0.3}, ${cx} ${cy - dr} C ${cx + dr} ${cy - dr * 0.3}, ${cx + cw} ${cy + dr * 0.6}, ${cx} ${tip} Z`;
};
// Six-pointed snowflake: centre lines + diagonal lines
const flake = (cx, cy, fr) => html`
${[-1, 1].map(sx => [-1, 1].map(sy => html`
`))}
`;
// Dry — just a faint dash
if (!hasRain && !hasSnow) {
return html`
`;
}
// Rain intensity → number of drops
const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3;
// Snow intensity → number of flakes
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
const dropColor = '#5090b0';
const dr = size * 0.13; // drop radius
const fr = size * 0.18; // flake arm length
// Layout: spread items evenly across the icon width
const items = (mixed ? rainDrops + snowFlakes : hasRain ? rainDrops : snowFlakes);
const spacing = size / (items + 1);
return html`
`;
}