New big and small scope sunset and sunrise simulation
New calculations for the day tabs
Added missing icons to simple view
New record popups
Week ahead description popups
Camping profile
Tweak to some of the indoor tems
This commit is contained in:
fraxle
2026-10-08 09:53:41 +01:00
parent 71860b9dd9
commit 862f631beb
22 changed files with 2169 additions and 429 deletions
+116 -40
View File
@@ -21,7 +21,7 @@ import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-h
import htm from '../vendor/htm.js';
import {
skyGradientForElev, skyFillForElev, grassFillForElev,
moonPhaseFraction,
moonPhaseFraction, sunLightState,
} from './utils.js';
import { getLensOverlaySVG } from './events.js';
@@ -38,6 +38,17 @@ function blendHex(a, b, 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('');
}
// Sun centre Y for a scope. The disk sits fully above the horizon at
// +0.27 deg, is half-set at -0.28 deg and fully gone at -0.83 deg (standard
// sunset incl. refraction), then keeps sinking so its clipped halo still
// lights the sky above the horizon through twilight.
// horizonY - y of the horizon line, sunR - disk radius,
// span - extra travel from horizon-resting to the zenith
function sunCentreY(elev, horizonY, sunR, span) {
const rim = Math.max(-1, Math.min(1, (elev + 0.28) / 0.55));
const arc = Math.sin(Math.max(-12, Math.min(90, elev)) * Math.PI / 180);
return horizonY - sunR * rim - span * arc;
}
// Tiny seeded PRNG (mulberry32). Deterministic so the scattered ground
// objects keep the same positions on every re-render (no jitter).
function seededRng(seed) {
@@ -265,8 +276,7 @@ export function VentPill({ checked, onChange, label, title, grpClass }) {
// value picks the clock-aligned bucket size used by aggregateRows()
// (1h = every hour, 4h = quarter-day blocks).
//
// "-" steps toward 1h (finer, more rows), "+" toward 4h (coarser).
// Direction follows the readout, so + always raises the number shown.
// Zoom-style: "+" zooms in toward 1h (finer, more rows), "-" zooms out toward 4h (coarser).
//
// props:
// value - 1..4
@@ -283,14 +293,14 @@ export function RowStepper({ value, onChange, showLabel = true, label = 'Rows',
return html`
<span class="row-stepper" role="group" aria-label="Row interval">
${showLabel && html`<span class="row-stepper-label">${label}</span>`}
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}>−</button>
<span class="row-stepper-value" aria-live="polite"
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
<button type="button" class="row-stepper-btn" disabled=${v >= max}
title="Coarser rows" aria-label="Coarser rows - more hours per row"
onClick=${() => step(v + 1)}>+</button>
onClick=${() => step(v + 1)}>−</button>
<span class="row-stepper-value" aria-live="polite"
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}>+</button>
</span>`;
}
@@ -311,7 +321,8 @@ export function RowStepper({ value, onChange, showLabel = true, label = 'Rows',
// - 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 }) {
export function SkyScope({ elev, dt, glob = 0, size = 26,
cc = null, ccLow = null, ccMid = null, ccHigh = null, visKm = null, rh = null }) {
const r = size / 2;
const innerR = r - 1.2;
@@ -319,23 +330,28 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const localHour = dt ? dt.getUTCHours() : 12;
const isRising = localHour < 12;
const skyGrad = skyGradientForElev(elev, isRising);
// Sun colour / glow / afterglow from elevation + atmosphere (see utils.js)
const light = sunLightState({ elev, isRising, cc, ccLow, ccMid, ccHigh, visKm, rh });
const skyGradBase = skyGradientForElev(elev, isRising);
// Horizon end of the sky picks up the glow colour so sky and sun agree
const skyGrad = { top: skyGradBase.top, bot: blendHex(skyGradBase.bot, light.glow, light.skyGlow * 0.35) };
const isDay = elev > 0; // sun only drawn when actually above horizon
const isDay = elev > 0;
const isTwil = elev > -8 && !isDay; // moon shown in twilight too
const isNight = elev <= -8;
const showSun = elev > -6; // halo still lights the sky just after sunset
// 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);
// Sun position - sinks behind the horizon (y = r) instead of overlapping the land
const sunR = Math.max(1.8, innerR * 0.24); // smaller disk; glow carries the strength
const sunY = sunCentreY(elev, r, sunR, innerR - 2.5 - sunR);
// 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 glowR = (sunR + 5 + radFrac * 10) * (1 + light.haze * 0.3); // total glow radius (haze spreads it)
const glowPeak = Math.min(1, 0.35 + radFrac * 0.45 + horizFrac * 0.15); // centre opacity
const haloFade = Math.max(0, Math.min(1, (elev + 6) / 6.3)); // halo dims as the sun sinks below
const phase = moonPhaseFraction(dt);
const moonR = innerR * 0.55;
@@ -343,8 +359,15 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
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'}`;
// uid must include every input that changes a gradient, or cells sharing an
// elevation would reuse each other's sun colours
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}` +
`-${light.glow.slice(1)}-${light.core.slice(1)}-${Math.round(light.skyGlow * 50)}` +
`-${Math.round(light.groundGlow * 50)}-${Math.round(light.diskAlpha * 20)}-${Math.round(radFrac * 50)}` +
`-${Math.round(light.haze * 20)}`;
const clipId = `clip-${uid}`;
const skyHalfId = `skyhalf-${uid}`;
const groundHalfId = `groundhalf-${uid}`;
const skyId = `sky-${uid}`;
const grassId = `grass-${uid}`;
@@ -355,6 +378,19 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<clipPath id=${clipId}>
<circle cx=${r} cy=${r} r=${innerR} />
</clipPath>
<!-- Sky / ground halves — the sun and its glow are cut at the horizon -->
<clipPath id=${skyHalfId}><rect x="0" y="0" width=${size} height=${r} /></clipPath>
<clipPath id=${groundHalfId}><rect x="0" y=${r} width=${size} height=${r} /></clipPath>
<!-- Horizon afterglow (sky) and warm wash (ground), stretched by the ellipse -->
<radialGradient id=${`skyglow-${uid}`}>
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.skyGlow} />
<stop offset="55%" stop-color=${light.glow} stop-opacity=${light.skyGlow * 0.4} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<radialGradient id=${`groundglow-${uid}`}>
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.groundGlow} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- 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} />
@@ -368,10 +404,10 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<!-- 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" />
<stop offset="0%" stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset="70%" stop-color=${light.sun} stop-opacity=${glowPeak * 0.5} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: lit area = white, shadow = black punched out -->
<mask id=${`moon-mask-${uid}`}>
@@ -388,15 +424,30 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<!-- Sky disk -->
<circle cx=${r} cy=${r} r=${innerR} fill=${`url(#${skyId})`} />
<!-- Horizon afterglow in the sky -->
${light.skyGlow > 0.01 && html`
<g clip-path=${`url(#${clipId})`}>
<ellipse cx=${r} cy=${r} rx=${innerR} ry=${innerR * (0.55 + 0.25 * light.haze)}
fill=${`url(#skyglow-${uid})`} clip-path=${`url(#${skyHalfId})`} />
</g>`}
<!-- 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`
<!-- Warm low-sun wash on the ground -->
${light.groundGlow > 0.01 && 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})`} />
<ellipse cx=${r} cy=${r} rx=${innerR * 0.9} ry=${innerR * 0.35}
fill=${`url(#groundglow-${uid})`} clip-path=${`url(#${groundHalfId})`} />
</g>`}
${showSun && html`
<g clip-path=${`url(#${clipId})`}>
<!-- Glow + sun disk as one seamless radial gradient circle, cut at the horizon -->
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`}
opacity=${haloFade} clip-path=${`url(#${skyHalfId})`} />
</g>`}
${(isTwil || isNight) && html`
@@ -593,7 +644,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
// -------------------------------------------------------------------
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 }) {
env = 'open', wind = 0, gust = 0, wd = null, rh = 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
@@ -630,8 +681,11 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
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);
// Sun colour / glow / afterglow from elevation + atmosphere (see utils.js).
// The horizon end of the sky picks up the glow colour before weather grey.
const light = sunLightState({ elev, isRising, cc, ccLow, ccMid, ccHigh, visKm, rh });
const skyTopTint = blendHex(skyGrad.top, isDay ? greyTarget : blendHex(greyTarget, '#0e1018', 0.4), tintAmt * 0.8);
const skyBotTint = blendHex(skyGrad.bot, greyTarget, tintAmt);
const skyBotTint = blendHex(blendHex(skyGrad.bot, light.glow, light.skyGlow * 0.35), greyTarget, tintAmt);
// Drifting cloud sprites, one strip per coverage layer. Each strip holds a
// few sprites duplicated +200px so the CSS drift wraps seamlessly.
@@ -910,16 +964,18 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
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;
const sunDrawR = 11; // smaller disk; glow carries the strength
// Sun sinks behind the horizon (y = cy) instead of overlapping the land
const sunY = sunCentreY(elev, cy, sunDrawR, lensR - 18 - sunDrawR);
const showSun = elev > -6; // halo still lights the sky just after sunset
// 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 sunGlowR = (sunDrawR + 26 + radFrac * 44) * (1 + light.haze * 0.3); // total glow radius (haze spreads it)
const sunGlowPeak = Math.min(1, 0.30 + radFrac * 0.45 + horizFrac * 0.15); // centre opacity
const haloFade = Math.max(0, Math.min(1, (elev + 6) / 6.3)); // halo dims as the sun sinks below
const moonDrawR = 15;
const moonY = cy - 45;
const shadowOffset = (1 - Math.abs(2 * phase - 1)) * 2 * moonDrawR;
@@ -1129,10 +1185,23 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
<!-- 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" />
<stop offset="0%" stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset=${`${Math.round(sunDrawR / sunGlowR * 100)}%`} stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset="70%" stop-color=${light.sun} stop-opacity=${sunGlowPeak * 0.5} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Sky / ground halves — the sun and its glow are cut at the horizon -->
<clipPath id="scope-sky-half"><rect x="0" y="0" width="200" height=${cy} /></clipPath>
<clipPath id="scope-ground-half"><rect x="0" y=${cy} width="200" height=${200 - cy} /></clipPath>
<!-- Horizon afterglow (sky) and warm wash (ground), stretched by the ellipse -->
<radialGradient id="scope-sky-afterglow">
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.skyGlow} />
<stop offset="55%" stop-color=${light.glow} stop-opacity=${light.skyGlow * 0.4} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<radialGradient id="scope-ground-glow">
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.groundGlow} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: white = lit area, black = shadow punched out -->
<mask id="scope-moon-mask">
@@ -1150,11 +1219,18 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
<!-- 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)" />
<!-- Horizon afterglow in the sky -->
<ellipse cx=${cx} cy=${cy} rx=${lensR} ry=${lensR * (0.55 + 0.25 * light.haze)}
fill="url(#scope-sky-afterglow)" clip-path="url(#scope-sky-half)" />
<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)" />
<!-- Warm low-sun wash on the ground -->
<ellipse cx=${cx} cy=${cy} rx=${lensR * 0.9} ry=${lensR * 0.35}
fill="url(#scope-ground-glow)" clip-path="url(#scope-ground-half)" />
${showSun && html`<${Fragment}>
<!-- Glow + sun disk as one seamless radial gradient circle, cut at the horizon -->
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)"
opacity=${haloFade} clip-path="url(#scope-sky-half)" />
</>`}
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
<!-- Lit crescent — full moon disk clipped by the phase mask -->