Files
houseplan-card/src/moon.ts
T
Claudeandclaude[bot] b998b0b34a feat(moon): the moon with any background, and its status in General settings (#718)
The owner decided on 30.09 that the moon is not part of the "Follow the Sun"
environment but a switch of its own: with a static background (global or a
space's own) the card showed no moon even with the switch on, and the switch
said nothing about why the moon was missing right now.

With a static background there is no environment, so the moon stands in its
own layer, `.hp-moon-sky`: the first child of `.stage` / `.hp-static-stage`,
the whole scene, no z-index, filter or will-change, under the plan by DOM
order, fading with the #101 View weight. Inside is the very #661 element, so
place, size, art and fades are unchanged, and a background switch moves it to
its new parent in the same render without a flicker. The phase comes from the
same `resolveDayCycle`, computed only while the moon is on and on View; without
`sun.sun` both cards keep their 30 s clock ticker and re-render only when the
phase changes (the environment is still compared by its whole fingerprint).

General settings get a second caption line under the moon switch
(`data-moon-status`): one snapshot per opening, judged by the lazy chunk as if
the switch were on, first reason wins (no home, day, below 3°, under 3 %),
numbers rounded and clamped below the threshold they missed. `moonStatus`
decides "shown" with the same `moonShownAt` as the element. It lives in a
WeakMap beside the draft, so it never makes the dialog dirty; a closed
opening's result is dropped. The dialog loads the chunk through the gate's
loader (`withMoon`), now shared by every caller while a load is in flight, so
there is still one fingerprint check and one retry token.

Bundle (same build, against origin/dev): initial View 300 072 -> 300 248 B gzip
(+176 B, under the 500 B of the spec; budget and ceiling not raised); lazy
editor 238 558 -> 238 991 B (+433 B, the line and English strings); lazy moon
11 385 -> 11 712 B (+327 B, layer CSS and status). `src/moon.ts` stays out of
the initial and the editor graph; bundle-budget now refuses an editor/moon
overlap. Monolith metrics: hostRefs 4 885 -> 4 888 — the three `host.` reads of
`src/editors/moon-status.ts` (hass, `_settingsDialog`, requestUpdate) through
its own three-member interface, not the editor port; the other five metrics
are unchanged. houseplan-editor-runtime.ts grows by two lines (import, call).

Tests: AC9/AC10/AC15 and the sky layer in test/moon.test.mjs (the #661
"static -> nothing" check inverted), AC14 and the opening lifecycle in
test/moon-settings.test.mjs, smokes demo/smoke_moon_static.mjs (AC1-AC6; AC1
and AC3 were red on dev) and demo/smoke_moon_status.mjs (AC11/AC12), AC7 in
smoke_daycycle_layer_budget. Golden: two new scenes
(static-bg-moon-gibbous-white-light, static-bg-moon-crescent-south-dark,
matrix v70), the harness checks the moon's parent by background and waits for
the status line in the General settings frames. Four new mutants; the clock
ticker one is a browser guard (201 at the guideline of 200).

Issue: #718
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:25:21 +00:00

215 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The moon behind the plan (#661; with any background since #718): pure
* astronomy, visibility rules, the status line and the phase mask. No DOM, no
* Lit — the lazy `moon-runtime` chunk renders from these, unit tests call them
* directly.
*
* Home Assistant publishes no moon altitude, so the card computes it from
* `hass.config.latitude/longitude` and the browser clock: the short Meeus
* series (as in SunCalc) plus the topocentric parallax, without refraction.
* Checked against JPL Horizons (airless) on the twelve points of the issue:
* altitude within 1.5°, illumination within 2.5 percentage points.
*/
import type { DayCyclePhase, DayCycleSource } from './sun';
/**
* C1: the same 3° as the window rays (`RAY_ELEVATION_MIN`), so both lights
* obey one horizon. A literal, not an import: every name the lazy chunk takes
* from the initial graph is one more export there (test/moon.test.mjs pins it).
*/
export const MOON_ELEVATION_MIN = 3;
/** C1: a thinner crescent reads as a scratch on a wall tablet. */
export const MOON_MIN_ILLUMINATION = 0.03;
const RAD = Math.PI / 180;
const DAY_MS = 86_400_000;
const J1970 = 2_440_588;
const J2000 = 2_451_545;
const OBLIQUITY = RAD * 23.4397;
const EARTH_RADIUS_KM = 6378.14;
const SUN_DISTANCE_KM = 149_598_000;
const daysSinceJ2000 = (date: Date): number => date.valueOf() / DAY_MS - 0.5 + J1970 - J2000;
const rightAscension = (l: number, b: number): number =>
Math.atan2(Math.sin(l) * Math.cos(OBLIQUITY) - Math.tan(b) * Math.sin(OBLIQUITY), Math.cos(l));
const declination = (l: number, b: number): number =>
Math.asin(Math.sin(b) * Math.cos(OBLIQUITY) + Math.cos(b) * Math.sin(OBLIQUITY) * Math.sin(l));
interface Equatorial { ra: number; dec: number; dist: number }
/** Geocentric moon: the short Meeus series, degrees per day as in SunCalc. */
function moonCoords(d: number): Equatorial {
const L = RAD * (218.316 + 13.176396 * d);
const M = RAD * (134.963 + 13.064993 * d);
const F = RAD * (93.272 + 13.22935 * d);
const l = L + RAD * 6.289 * Math.sin(M);
const b = RAD * 5.128 * Math.sin(F);
return { ra: rightAscension(l, b), dec: declination(l, b), dist: 385_001 - 20_905 * Math.cos(M) };
}
function sunCoords(d: number): Equatorial {
const M = RAD * (357.5291 + 0.98560028 * d);
const C = RAD * (1.9148 * Math.sin(M) + 0.02 * Math.sin(2 * M) + 0.0003 * Math.sin(3 * M));
const L = M + C + RAD * 102.9372 + Math.PI;
return { ra: rightAscension(L, 0), dec: declination(L, 0), dist: SUN_DISTANCE_KM };
}
export interface MoonPosition {
/** Topocentric altitude, degrees, airless (no refraction). */
altitude: number;
/** Degrees clockwise from north. */
azimuth: number;
}
/** C2: topocentric altitude/azimuth; the parallax `h − π·cos h` matters by the horizon. */
export function moonPosition(date: Date, latitude: number, longitude: number): MoonPosition {
const d = daysSinceJ2000(date);
const phi = RAD * latitude;
const c = moonCoords(d);
const H = RAD * (280.16 + 360.9856235 * d) + RAD * longitude - c.ra;
const geocentric = Math.asin(Math.sin(phi) * Math.sin(c.dec) + Math.cos(phi) * Math.cos(c.dec) * Math.cos(H));
const parallax = Math.asin(EARTH_RADIUS_KM / c.dist);
const altitude = geocentric - parallax * Math.cos(geocentric);
const south = Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(phi) - Math.tan(c.dec) * Math.cos(phi));
return {
altitude: altitude / RAD,
azimuth: (((south / RAD + 180) % 360) + 360) % 360,
};
}
export interface MoonIllumination {
/** Illuminated fraction k, 0…1, from the Sun–Moon elongation. */
fraction: number;
/** Growing towards full; by the sign of the bright limb's position angle. */
waxing: boolean;
}
/** C2: `k = (1 + cos i) / 2`; independent of where on Earth the plan is. */
export function moonIllumination(date: Date): MoonIllumination {
const d = daysSinceJ2000(date);
const s = sunCoords(d);
const m = moonCoords(d);
const elongation = Math.acos(Math.sin(s.dec) * Math.sin(m.dec)
+ Math.cos(s.dec) * Math.cos(m.dec) * Math.cos(s.ra - m.ra));
const inc = Math.atan2(s.dist * Math.sin(elongation), m.dist - s.dist * Math.cos(elongation));
const angle = Math.atan2(Math.cos(s.dec) * Math.sin(s.ra - m.ra),
Math.sin(s.dec) * Math.cos(m.dec) - Math.cos(s.dec) * Math.sin(m.dec) * Math.cos(s.ra - m.ra));
return { fraction: (1 + Math.cos(inc)) / 2, waxing: angle < 0 };
}
/** C1 thresholds on an already computed sky: the environment phase, 3° and 3 %. */
export function moonShownAt(phase: DayCyclePhase, altitude: number, fraction: number): boolean {
return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;
}
export interface MoonView {
visible: boolean;
/** Illuminated fraction quantised to 0.01: the render changes only with the fingerprint (C3). */
k: number;
}
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
/**
* C1 (#718 K1): the card is the gate for the rest — a View surface, any
* background; `phase` is `resolveDayCycle` whether or not an environment is
* drawn. `settings` are the global settings, `config` is `hass.config`.
*/
export function moonView(settings: unknown, phase: DayCyclePhase, config: unknown, now: Date): MoonView {
const { fraction } = moonIllumination(now);
const k = Math.round(fraction * 100) / 100;
const { latitude, longitude } = (config ?? {}) as { latitude?: unknown; longitude?: unknown };
if ((settings as { moon?: unknown } | null | undefined)?.moon !== true
|| !finite(latitude) || !finite(longitude)) return { visible: false, k };
return { visible: moonShownAt(phase, moonPosition(now, latitude, longitude).altitude, fraction), k };
}
/** #718 K7: why the moon is or is not shown — the first reason that holds, in this order. */
export type MoonStatusReason = 'shown' | 'no_home' | 'day_sun' | 'day_clock' | 'low' | 'new';
/** The reason with its numbers already rounded: the dialog only puts them into the text. */
export interface MoonStatus {
reason: MoonStatusReason;
/** Moon altitude, whole degrees (`shown`, `low`). */
alt?: number;
/** Illuminated share, whole per cent (`shown`, `new`). */
pct?: number;
/** The `sun.sun` elevation, whole degrees (`day_sun`). */
sun?: number;
}
/** The sky the status is judged on, as ready numbers (AC9). */
export interface MoonSky {
/** Finite `hass.config.latitude/longitude`. */
home: boolean;
phase: DayCyclePhase;
source: DayCycleSource;
/** `sun.sun` elevation; null without it. */
sun: number | null;
altitude: number;
fraction: number;
}
/**
* #718 K7/K8: no home, day, below 3°, under 3 % — the first that holds; else
* shown, decided by the same `moonShownAt` as the element. A hidden reason
* never shows the threshold it missed: «at 3°, shows from 3°» reads as a bug,
* so its number stops at 2.
*/
export function moonStatusOf(sky: MoonSky): MoonStatus {
if (!sky.home) return { reason: 'no_home' };
if (sky.phase === 'day') return sky.source === 'sun' ? { reason: 'day_sun', sun: Math.round(sky.sun ?? 0) } : { reason: 'day_clock' };
const alt = Math.round(sky.altitude);
const pct = Math.round(sky.fraction * 100);
if (moonShownAt(sky.phase, sky.altitude, sky.fraction)) return { reason: 'shown', alt, pct };
return sky.altitude < MOON_ELEVATION_MIN ? { reason: 'low', alt: Math.min(alt, 2) } : { reason: 'new', pct: Math.min(pct, 2) };
}
/**
* #718 K7: the status for `hass.config` and a day-cycle sample taken at `now`,
* as if the switch were on — the moon no longer depends on the background, so
* one status serves the whole installation. `sun` is the `sun.sun` elevation.
*/
export function moonStatus(
config: unknown, state: { phase: DayCyclePhase; source: DayCycleSource }, sun: number | null, now: Date,
): MoonStatus {
const { latitude, longitude } = (config ?? {}) as { latitude?: unknown; longitude?: unknown };
const home = finite(latitude) && finite(longitude);
return moonStatusOf({
home, phase: state.phase, source: state.source, sun,
altitude: home ? moonPosition(now, latitude, longitude).altitude : 0,
fraction: moonIllumination(now).fraction,
});
}
/** C3: equal fingerprint → no re-render. The lit side is fixed, so k is all the shape. */
export function moonFingerprint(view: MoonView): string {
return `${view.visible ? 1 : 0}|${view.k.toFixed(2)}`;
}
/** Disc of the designer's pack: `viewBox 0 0 512 512`, centre 256, radius 240. */
export const MOON_BOX = 512;
export const MOON_R = 240;
/** Below this the terminator is feathered in the mask; a full disc keeps the art's own edge. */
export const MOON_SHARP_FROM = 0.995;
/**
* C5, owner 2026-09-29: the lit side is always the LEFT one, in both
* hemispheres; waning runs the waxing states backwards. The lit region is the
* left half of the box (its outer arc runs along the box, r 256, never along
* the limb, so the art keeps its anti-aliased edge) plus or minus the
* terminator half-ellipse `R × R·|2k−1|`: bulging right when k > 0.5.
*/
export function moonPhasePath(k: number): string {
const c = MOON_BOX / 2;
const top = c - MOON_R;
const bottom = c + MOON_R;
const rx = MOON_R * Math.abs(2 * Math.min(1, Math.max(0, k)) - 1);
const terminator = rx < 0.5
? `L${c} ${top}`
: `A${rx.toFixed(2)} ${MOON_R} 0 0 ${k > 0.5 ? 0 : 1} ${c} ${top}`;
return `M${c} 0A${c} ${c} 0 0 0 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;
}