mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
formCard, segmented, subsection, textLink, toggleRow, unitInput,
|
||||
} from './form-kit';
|
||||
import { forgetGeneralBaseline, generalDirty, generalProblems, type GeneralSettingsDraft } from './general-form-state';
|
||||
import { moonStatusOf, moonStatusText } from './moon-status';
|
||||
import { langOf, type I18nKey } from '../i18n';
|
||||
import { settingsT, type SettingsI18nKey } from '../i18n/settings';
|
||||
import { supportT } from '../i18n/support';
|
||||
@@ -76,6 +77,8 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
|
||||
};
|
||||
const glowProblem = problemFor('gs-glow-radius');
|
||||
const northProblem = problemFor('gs-north');
|
||||
// #718 K7: the second caption line, once the moon chunk judged this opening.
|
||||
const moonStatus = moonStatusOf(host);
|
||||
const zigbeeHelp = hasTopologyTranslation(lang, 'help') && hasTopologyTranslation(lang, 'help_aria')
|
||||
? html`<hp-help data-help-key="topology.help" .text=${topologyT(lang, 'help')} .ariaLabel=${topologyT(lang, 'help_aria')}></hp-help>`
|
||||
: nothing;
|
||||
@@ -227,7 +230,10 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
|
||||
${renderSunRayOriginSegment(d.sunRayOrigin, (key) => t(key), (sunRayOrigin) => set({ sunRayOrigin }))}
|
||||
${toggleRow({
|
||||
id: 'gs-moon', icon: 'mdi:moon-waning-crescent',
|
||||
title: st('gs.moon'), caption: st('gs.moon_hint'),
|
||||
title: st('gs.moon'),
|
||||
caption: moonStatus
|
||||
? html`${st('gs.moon_hint')}<span style="display:block" data-moon-status=${moonStatus.reason}>${moonStatusText(moonStatus, st)}</span>`
|
||||
: st('gs.moon_hint'),
|
||||
checked: d.moon, onChange: (v) => set({ moon: v }),
|
||||
})}`,
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* #718 K7: the «Now: …» line under the moon switch in General settings.
|
||||
*
|
||||
* One snapshot per opening — `now`, `hass.config`, `sun.sun` — judged by the
|
||||
* lazy moon chunk as if the switch were on: the moon no longer depends on the
|
||||
* background, so one status serves the whole installation, whatever the
|
||||
* switch, the background segment or the spaces say. It lives beside the
|
||||
* draft, never in it (`generalDraftKey` does not see it), so the line arriving
|
||||
* does not make the dialog dirty. The result of an opening that was closed in
|
||||
* the meantime is dropped; until the chunk is here, or when it failed, there
|
||||
* is no line and the hint alone stays.
|
||||
*/
|
||||
import { withMoon } from '../moon-gate';
|
||||
import { resolveDayCycle, sunStateOf } from '../sun';
|
||||
import type { MoonStatus, MoonStatusReason } from '../moon';
|
||||
import type { SettingsI18nKey } from '../i18n/settings';
|
||||
|
||||
interface MoonStatusHost {
|
||||
hass?: unknown;
|
||||
_settingsDialog: unknown;
|
||||
requestUpdate(): void;
|
||||
}
|
||||
|
||||
interface Opening { status?: MoonStatus }
|
||||
|
||||
const openings = new WeakMap<object, Opening>();
|
||||
|
||||
/** Called by `_openSettingsDialog` once the draft exists: asks for this opening's status. */
|
||||
export function openMoonStatus(host: MoonStatusHost, now: Date = new Date()): void {
|
||||
const opening: Opening = {};
|
||||
openings.set(host, opening);
|
||||
const hass = host.hass as { config?: unknown } | undefined;
|
||||
const state = resolveDayCycle(hass, now);
|
||||
// With `source === 'sun'` this is the very elevation `dayCycleSunOf` read.
|
||||
const sun = sunStateOf(hass)?.elevation ?? null;
|
||||
withMoon((moon) => {
|
||||
if (openings.get(host) !== opening || !host._settingsDialog) return;
|
||||
opening.status = moon.moonStatus(hass?.config, state, sun, now);
|
||||
host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
/** The status of the current opening, once the chunk has judged it. */
|
||||
export function moonStatusOf(host: object): MoonStatus | undefined {
|
||||
return openings.get(host)?.status;
|
||||
}
|
||||
|
||||
/** Literal keys (#502): the dead-key gate does not read a key glued from the reason. */
|
||||
const MOON_STATUS_KEYS: Readonly<Record<MoonStatusReason, SettingsI18nKey>> = {
|
||||
shown: 'gs.moon_status_shown',
|
||||
no_home: 'gs.moon_status_no_home',
|
||||
day_sun: 'gs.moon_status_day_sun',
|
||||
day_clock: 'gs.moon_status_day_clock',
|
||||
low: 'gs.moon_status_low',
|
||||
new: 'gs.moon_status_new',
|
||||
};
|
||||
|
||||
/** A signed whole number: the minus is U+2212, no plus; −0 reads as 0. */
|
||||
const signed = (value = 0): string => (value < 0 ? `−${-value}` : String(Math.abs(value)));
|
||||
|
||||
/** The line's text; `st` is the dialog's `settingsT` for its language. */
|
||||
export function moonStatusText(
|
||||
status: MoonStatus, st: (key: SettingsI18nKey, vars: Record<string, string>) => string,
|
||||
): string {
|
||||
return st(MOON_STATUS_KEYS[status.reason], {
|
||||
alt: signed(status.alt), pct: signed(status.pct), sun: signed(status.sun),
|
||||
});
|
||||
}
|
||||
+12
-6
@@ -44,11 +44,11 @@ import { type ResizeAreaPlacement } from './resize-labels';
|
||||
import {
|
||||
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, sunRayOriginOf,
|
||||
sunStateOf, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
|
||||
rayStops, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
|
||||
rayStops, resolveDayCycle, type DayCycleState,
|
||||
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin,
|
||||
} from './sun';
|
||||
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
|
||||
import { moonLayer } from './moon-gate';
|
||||
import { dayCycleClock, moonLayer, moonSkyState } from './moon-gate';
|
||||
import { renderPaperShapes, type PaperShape } from './render/paper-scene';
|
||||
import {
|
||||
furnitureGraphic, furnitureArtIsLazy,
|
||||
@@ -9796,15 +9796,20 @@ export class HouseplanCard extends LitElement {
|
||||
return resolveDayCycle(this._renderPlanHass, now);
|
||||
}
|
||||
|
||||
/** #718 K1/K5: the moon's phase without an environment (static background, moon on, View). */
|
||||
private _moonSkyState(): DayCycleState | null {
|
||||
return moonSkyState(this._sunGlobal(), this._effBgMode() === 'daynight',
|
||||
this._modeTransitionVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0), this._renderPlanHass);
|
||||
}
|
||||
|
||||
private _dayCycleTick = (): void => {
|
||||
if (!this.isConnected || this.ownerDocument.visibilityState === 'hidden') return;
|
||||
const state = this._dayCycleState();
|
||||
const [state, key] = dayCycleClock(this._dayCycleState(), this._moonSkyState());
|
||||
if (!state) {
|
||||
if (this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; }
|
||||
this._dayCycleClockKey = '';
|
||||
return;
|
||||
}
|
||||
const key = dayCycleFingerprint(state);
|
||||
if (key === this._dayCycleClockKey) return;
|
||||
this._dayCycleClockKey = key;
|
||||
this.requestUpdate();
|
||||
@@ -9812,8 +9817,8 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
/** Arm a 30 s timer only for the browser-clock fallback while visible. */
|
||||
private _syncDayCycleClock(): void {
|
||||
const state = this._dayCycleState();
|
||||
this._dayCycleClockKey = state ? dayCycleFingerprint(state) : '';
|
||||
const [state, key] = dayCycleClock(this._dayCycleState(), this._moonSkyState());
|
||||
this._dayCycleClockKey = key;
|
||||
const needsTimer = state?.source === 'clock'
|
||||
&& this.ownerDocument.visibilityState !== 'hidden' && this.isConnected;
|
||||
if (needsTimer && !this._dayCycleTimer) {
|
||||
@@ -10868,6 +10873,7 @@ export class HouseplanCard extends LitElement {
|
||||
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
|
||||
@pointercancel=${(e: PointerEvent) => this._stagePointerCancel(e)}>
|
||||
${renderDayCycleEnvironment(dayCycle, dayCycleWeight, moonLayer(this, this._sunGlobal(), dayCycle))}
|
||||
${moonLayer(this, this._sunGlobal(), this._moonSkyState(), dayCycleWeight)}
|
||||
${this._editorRuntime ? this._renderEditorSecondary() : nothing}
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
|
||||
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
|
||||
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
} from './space-dialog';
|
||||
import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state';
|
||||
import { generalProblems, moonDraftOf, rememberGeneralBaseline, writeMoonSetting } from './editors/general-form-state';
|
||||
import { openMoonStatus } from './editors/moon-status';
|
||||
import { rememberRoomBaseline } from './editors/room-form-state';
|
||||
import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state';
|
||||
import { commitPlanOptimization } from './plan-optimize-write';
|
||||
@@ -8464,6 +8465,7 @@ public _openSettingsDialog = (): void => {
|
||||
};
|
||||
// #600 К10: снимок на момент открытия — от него считается «есть изменения».
|
||||
rememberGeneralBaseline(this.host, this.host._settingsDialog);
|
||||
openMoonStatus(this.host); // #718 K7: the status line, outside the draft
|
||||
};
|
||||
|
||||
public _openSupportDialog = (): void => {
|
||||
|
||||
@@ -56,7 +56,13 @@
|
||||
"gs.bg_theme_hint": "Hintergrund des Themes wird verwendet.",
|
||||
"gs.sun_rays_hint": "Sonnenstrahlen durch Fensteröffnungen zeichnen.",
|
||||
"gs.moon": "Mond über dem Plan in Dämmerung und Nacht",
|
||||
"gs.moon_hint": "Erscheint im Hintergrund hinter dem Plan ab 3° über dem Horizont, außer um Neumond; berechnet aus den Koordinaten des Zuhauses in Home Assistant.",
|
||||
"gs.moon_hint": "Erscheint hinter dem Plan bei jedem Hintergrund, ab 3° über dem Horizont, außer um Neumond; berechnet aus den Koordinaten des Zuhauses in Home Assistant.",
|
||||
"gs.moon_status_shown": "Jetzt: sichtbar ({alt}° über dem Horizont, {pct} % beleuchtet).",
|
||||
"gs.moon_status_no_home": "Jetzt: nicht sichtbar (in Home Assistant ist der Standort des Zuhauses nicht festgelegt).",
|
||||
"gs.moon_status_day_sun": "Jetzt: nicht sichtbar (die Sonne steht {sun}° über dem Horizont; der Mond erscheint, sobald sie unter 6° steht).",
|
||||
"gs.moon_status_day_clock": "Jetzt: nicht sichtbar (Tag laut Uhr, 08:00–18:00: Home Assistant liefert keine sun.sun-Daten).",
|
||||
"gs.moon_status_low": "Jetzt: nicht sichtbar (der Mond steht in {alt}° Höhe; er erscheint ab 3° über dem Horizont).",
|
||||
"gs.moon_status_new": "Jetzt: nicht sichtbar (Neumond, {pct} % beleuchtet; sichtbar ab 3 %).",
|
||||
"gs.error_glow_radius": "Geben Sie einen Radius größer als null ein.",
|
||||
"gs.error_north": "Geben Sie eine ganze Zahl zwischen 0° und 359° ein.",
|
||||
"gs.opacity_of": "Deckkraft: {name}",
|
||||
|
||||
@@ -56,7 +56,13 @@
|
||||
"gs.bg_theme_hint": "Using the theme background.",
|
||||
"gs.sun_rays_hint": "Draw sun rays through window openings.",
|
||||
"gs.moon": "Moon over the plan at dusk and night",
|
||||
"gs.moon_hint": "Shown on the background behind the plan from 3° above the horizon, except around new moon; computed from the home coordinates in Home Assistant.",
|
||||
"gs.moon_hint": "Shown behind the plan with any background, from 3° above the horizon, except around new moon; computed from the home coordinates in Home Assistant.",
|
||||
"gs.moon_status_shown": "Now: shown ({alt}° above the horizon, {pct}% lit).",
|
||||
"gs.moon_status_no_home": "Now: not shown (the home location is not set in Home Assistant).",
|
||||
"gs.moon_status_day_sun": "Now: not shown (the sun is {sun}° above the horizon; the moon shows once it is below 6°).",
|
||||
"gs.moon_status_day_clock": "Now: not shown (daytime by the clock, 08:00–18:00: Home Assistant has no sun.sun data).",
|
||||
"gs.moon_status_low": "Now: not shown (the moon is at {alt}°; it shows from 3° above the horizon).",
|
||||
"gs.moon_status_new": "Now: not shown (new moon, {pct}% lit; it shows from 3%).",
|
||||
"gs.error_glow_radius": "Enter a radius greater than zero.",
|
||||
"gs.error_north": "Enter a whole number between 0° and 359°.",
|
||||
"gs.opacity_of": "{name} opacity",
|
||||
|
||||
@@ -56,7 +56,13 @@
|
||||
"gs.bg_theme_hint": "Fond du thème utilisé.",
|
||||
"gs.sun_rays_hint": "Tracer les rayons du soleil à travers les ouvertures des fenêtres.",
|
||||
"gs.moon": "Lune sur le plan au crépuscule et la nuit",
|
||||
"gs.moon_hint": "Affichée sur le fond derrière le plan à partir de 3° au-dessus de l’horizon, sauf autour de la nouvelle lune ; calculée à partir des coordonnées du domicile dans Home Assistant.",
|
||||
"gs.moon_hint": "Affichée derrière le plan quel que soit le fond, à partir de 3° au-dessus de l’horizon, sauf autour de la nouvelle lune ; calculée à partir des coordonnées du domicile dans Home Assistant.",
|
||||
"gs.moon_status_shown": "Maintenant : affichée ({alt}° au-dessus de l’horizon, éclairée à {pct} %).",
|
||||
"gs.moon_status_no_home": "Maintenant : non affichée (la position du domicile n’est pas définie dans Home Assistant).",
|
||||
"gs.moon_status_day_sun": "Maintenant : non affichée (le soleil est à {sun}° au-dessus de l’horizon ; la lune apparaît dès qu’il passe sous 6°).",
|
||||
"gs.moon_status_day_clock": "Maintenant : non affichée (jour selon l’horloge, 08:00–18:00 : Home Assistant n’a pas de données sun.sun).",
|
||||
"gs.moon_status_low": "Maintenant : non affichée (la lune est à {alt}° ; elle apparaît à partir de 3° au-dessus de l’horizon).",
|
||||
"gs.moon_status_new": "Maintenant : non affichée (nouvelle lune, éclairée à {pct} % ; visible à partir de 3 %).",
|
||||
"gs.error_glow_radius": "Saisissez un rayon supérieur à zéro.",
|
||||
"gs.error_north": "Saisissez un nombre entier entre 0° et 359°.",
|
||||
"gs.opacity_of": "Opacité : {name}",
|
||||
|
||||
@@ -56,7 +56,13 @@
|
||||
"gs.bg_theme_hint": "Используется фон темы.",
|
||||
"gs.sun_rays_hint": "Рисовать солнечные лучи через оконные проёмы.",
|
||||
"gs.moon": "Луна на плане в сумерках и ночью",
|
||||
"gs.moon_hint": "Видна на фоне за планом при высоте от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.",
|
||||
"gs.moon_hint": "Видна за планом при любом фоне, от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.",
|
||||
"gs.moon_status_shown": "Сейчас: показывается (на {alt}° над горизонтом, освещено {pct} %).",
|
||||
"gs.moon_status_no_home": "Сейчас: не показывается (в Home Assistant не заданы координаты дома).",
|
||||
"gs.moon_status_day_sun": "Сейчас: не показывается (Солнце на {sun}° над горизонтом, луна видна, когда оно ниже 6°).",
|
||||
"gs.moon_status_day_clock": "Сейчас: не показывается (день по часам, 08:00–18:00: в Home Assistant нет данных sun.sun).",
|
||||
"gs.moon_status_low": "Сейчас: не показывается (луна на высоте {alt}°, видна от 3° над горизонтом).",
|
||||
"gs.moon_status_new": "Сейчас: не показывается (новолуние, освещено {pct} %, видна от 3 %).",
|
||||
"gs.error_glow_radius": "Введите радиус больше нуля.",
|
||||
"gs.error_north": "Введите целое число от 0° до 359°.",
|
||||
"gs.opacity_of": "Прозрачность: {name}",
|
||||
|
||||
+65
-21
@@ -1,50 +1,94 @@
|
||||
/**
|
||||
* The moon's only foothold in the initial View graph (#661, C7): whether the
|
||||
* lazy `moon-runtime` chunk is wanted, and one load per page for every card on
|
||||
* it. It is wanted when the moon is switched on, the environment exists (the
|
||||
* effective background follows the sun, on a View surface) and it is not
|
||||
* daytime. Everything else — astronomy, art, element, its 30 s ticker — lives
|
||||
* in the chunk.
|
||||
* it. It is wanted when the moon is switched on on a View surface and it is
|
||||
* not daytime — with any background (#718 K1): over "Follow the Sun" the moon
|
||||
* lives in the environment, over a static one in its own sky layer — and when
|
||||
* General settings open, for the status line (#718 K6). Everything else —
|
||||
* astronomy, art, element, layer, its 30 s ticker, the status — lives in the
|
||||
* chunk.
|
||||
*
|
||||
* The contract of the other lazy runtimes, in the fewest bytes (the initial
|
||||
* graph sits at its budget): a chunk from another build is never installed and
|
||||
* never asked for again; a failed load leaves no moon and is retried at most
|
||||
* every 30 s, on the next render, from the content-hashed URL with a fresh
|
||||
* every 30 s, on the next request, from the content-hashed URL with a fresh
|
||||
* query so the browser's cached failure is not replayed.
|
||||
*/
|
||||
import { nothing, type TemplateResult } from 'lit';
|
||||
import { ENTRY_BUILD_FINGERPRINT } from './editor-runtime-loader';
|
||||
import type { DayCycleState } from './sun';
|
||||
import { dayCycleFingerprint, resolveDayCycle, type DayCycleState } from './sun';
|
||||
|
||||
type MoonRuntime = typeof import('./moon-runtime');
|
||||
export type MoonHost = import('./moon-runtime').MoonHost;
|
||||
|
||||
const MOON_RETRY_ASSET = '__HOUSEPLAN_MOON_RETRY_ASSET__';
|
||||
let runtime: MoonRuntime | null = null;
|
||||
/** The load in flight: every caller meanwhile waits for the same one. */
|
||||
let loading: Promise<void> | null = null;
|
||||
/** Earliest next attempt; 0 before the first, Infinity after a foreign build. */
|
||||
let nextAttempt = 0;
|
||||
|
||||
const moonOn = (settings: unknown): boolean => (settings as { moon?: unknown } | null | undefined)?.moon === true;
|
||||
|
||||
/**
|
||||
* The moon element for `renderDayCycleEnvironment`. `state` is the
|
||||
* environment (null: no environment, so no moon); `settings` the global
|
||||
* settings, where only an explicit `moon: true` counts (C1). Nothing until the
|
||||
* chunk is here; the card that asked re-renders when it arrives.
|
||||
* `use` gets the chunk: at once when it is here, else when the page-wide load
|
||||
* brings it. A failure, a load still barred by the 30 s retry or a chunk of
|
||||
* another build never call it.
|
||||
*/
|
||||
export function moonLayer(
|
||||
host: MoonHost | undefined, settings: unknown, state: DayCycleState | null,
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!host || !state || (settings as { moon?: unknown } | null | undefined)?.moon !== true) return nothing;
|
||||
if (!runtime && state.phase !== 'day' && Date.now() >= nextAttempt) {
|
||||
export function withMoon(use: (moon: MoonRuntime) => void): void {
|
||||
if (runtime) { use(runtime); return; }
|
||||
if (!loading && Date.now() >= nextAttempt) {
|
||||
const retry = nextAttempt > 0;
|
||||
nextAttempt = Date.now() + 30_000;
|
||||
(retry
|
||||
loading = (retry
|
||||
? import(/* @vite-ignore */ new URL(`${MOON_RETRY_ASSET}?${nextAttempt}`, import.meta.url).href) as Promise<MoonRuntime>
|
||||
: import('./moon-runtime')
|
||||
).then((module) => {
|
||||
if (module.MOON_RUNTIME_FINGERPRINT !== ENTRY_BUILD_FINGERPRINT) { nextAttempt = Infinity; return; }
|
||||
runtime = module;
|
||||
host.requestUpdate();
|
||||
}, () => undefined);
|
||||
loading = null;
|
||||
if (module.MOON_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) runtime = module;
|
||||
else nextAttempt = Infinity;
|
||||
}, () => { loading = null; });
|
||||
}
|
||||
return runtime?.renderMoon(host, settings, state.phase) ?? nothing;
|
||||
void loading?.then(() => { if (runtime) use(runtime); });
|
||||
}
|
||||
|
||||
/**
|
||||
* #718 K1/K5: the phase the moon uses where no environment is drawn — a static
|
||||
* background (global or the space's own), the moon switched on, a View
|
||||
* surface (`viewWeight` of #101 above 0). Anything else computes nothing.
|
||||
*/
|
||||
export function moonSkyState(
|
||||
settings: unknown, daynight: boolean, viewWeight: number, hass: unknown, now: Date | number = new Date(),
|
||||
): DayCycleState | null {
|
||||
return !daynight && viewWeight > 0 && moonOn(settings) ? resolveDayCycle(hass, now) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #718 K5: what the 30 s clock ticker compares — the whole environment (its
|
||||
* sun moves every minute), but only the phase of the moon's sky, so a static
|
||||
* background re-renders at 08:00 and 18:00, not every minute.
|
||||
*/
|
||||
export function dayCycleClock(
|
||||
env: DayCycleState | null, sky: DayCycleState | null,
|
||||
): [DayCycleState | null, string] {
|
||||
return [env ?? sky, env ? dayCycleFingerprint(env) : sky ? sky.phase : ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* The moon element. `state` is the day-cycle sample (null: no moon);
|
||||
* `settings` the global settings, where only an explicit `moon: true` counts
|
||||
* (C1). Without `sky` the element goes into `renderDayCycleEnvironment`; with
|
||||
* it (the View weight) it comes in its own layer for a static background.
|
||||
* Nothing until the chunk is here; the card that asked re-renders when it
|
||||
* arrives.
|
||||
*/
|
||||
export function moonLayer(
|
||||
host: MoonHost | undefined, settings: unknown, state: DayCycleState | null, sky?: number,
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!host || !state || !moonOn(settings)) return nothing;
|
||||
if (!runtime && state.phase !== 'day') withMoon(() => host.requestUpdate());
|
||||
if (!runtime) return nothing;
|
||||
return sky === undefined
|
||||
? runtime.renderMoon(host, settings, state.phase)
|
||||
: runtime.renderMoonSky(host, settings, state.phase, sky);
|
||||
}
|
||||
|
||||
+37
-14
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* The lazy moon chunk (#661, C7): astronomy, the designer's art, the element
|
||||
* template and the moon's own 30 s ticker. `moon-gate.ts` imports it only
|
||||
* while the moon is switched on, the background follows the sun and it is not
|
||||
* daytime; until then the initial View graph carries none of this.
|
||||
* template, the moon's own 30 s ticker and the status of General settings
|
||||
* (#718). `moon-gate.ts` imports it while the moon is switched on on a View
|
||||
* surface and it is not daytime, or when General settings open; until then
|
||||
* the initial View graph carries none of this.
|
||||
*
|
||||
* One element inside `.hp-day-cycle-env`, after the four phase layers: above
|
||||
* the gradients and the sun glow, below the plan paper and everything else
|
||||
* (owner decision 7). Its styles travel with it, so the View card, kiosk and
|
||||
* `houseplan-space-card` get the same moon without growing their own CSS.
|
||||
* One element behind the plan. Over "Follow the Sun" it is the last child of
|
||||
* `.hp-day-cycle-env`, after the four phase layers: above the gradients and
|
||||
* the sun glow, below the plan paper and everything else (owner decision 7).
|
||||
* With a static background there is no environment (#718 K3): the same element
|
||||
* stands in its own layer `.hp-moon-sky`, the first child of the scene, under
|
||||
* the plan by DOM order. Its styles travel with it, so the View card, kiosk
|
||||
* and `houseplan-space-card` get the same moon without growing their own CSS.
|
||||
*/
|
||||
import { html, nothing, type TemplateResult } from 'lit';
|
||||
import { MOON_ART } from './moon-art.generated';
|
||||
@@ -16,6 +20,9 @@ import {
|
||||
} from './moon';
|
||||
import type { DayCyclePhase } from './sun';
|
||||
|
||||
/** #718 K7: the status of General settings, judged where the moon is decided. */
|
||||
export { moonStatus } from './moon';
|
||||
|
||||
export const MOON_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
/** The card that shows the moon: the View card or `houseplan-space-card`. */
|
||||
@@ -30,12 +37,16 @@ export interface MoonHost {
|
||||
export const MOON_TICK_MS = 30_000;
|
||||
|
||||
/**
|
||||
* C6: box `min(200px, 25cqmin)` measured on the scene (the environment is the
|
||||
* size container), 5 % inset, never moved by pan or zoom, never hit-tested.
|
||||
* C6: box `min(200px, 25cqmin)` measured on the scene (the environment or the
|
||||
* sky layer is the size container, both the whole scene), 5 % inset, never
|
||||
* moved by pan or zoom, never hit-tested.
|
||||
* C4: opacity only, 2 s on the background curve; reduced motion — instant.
|
||||
* C8: no CSS filter and no will-change on the element.
|
||||
* C8: no CSS filter and no will-change on the element. #718 K3: the sky layer
|
||||
* has no z-index either — it lies under the plan by DOM order, as the
|
||||
* environment does.
|
||||
*/
|
||||
const MOON_CSS = '.hp-day-cycle-env{container-type:size}'
|
||||
const MOON_CSS = '.hp-day-cycle-env,.hp-moon-sky{container-type:size}'
|
||||
+ '.hp-moon-sky{position:absolute;inset:0;overflow:hidden;pointer-events:none}'
|
||||
+ '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;'
|
||||
+ 'left:calc(var(--hp-moon-box)*.05);top:calc(var(--hp-moon-box)*.05);'
|
||||
+ 'width:var(--hp-moon-box);height:var(--hp-moon-box);opacity:0;pointer-events:none;'
|
||||
@@ -63,8 +74,8 @@ function forget(host: MoonHost, watch: MoonWatch): void {
|
||||
/**
|
||||
* C3: between renders only the clock moves the moon. Every 30 s the tick
|
||||
* recomputes it from the last rendered inputs; an equal fingerprint costs no
|
||||
* render. A host that re-rendered without a moon (switched off, editor mode,
|
||||
* static background) or left the page drops its ticker; a hidden page skips.
|
||||
* render. A host that re-rendered without a moon (switched off, editor mode)
|
||||
* or left the page drops its ticker; a hidden page skips.
|
||||
*/
|
||||
export function moonTick(host: MoonHost, now: Date = new Date()): void {
|
||||
const watch = watches.get(host);
|
||||
@@ -93,7 +104,7 @@ function remember(host: MoonHost, settings: unknown, phase: DayCyclePhase, view:
|
||||
}
|
||||
|
||||
/**
|
||||
* The element stays in the environment while the moon is switched on, at
|
||||
* The element stays in its parent while the moon is switched on, at
|
||||
* opacity 0 when hidden, so rising, setting, the new-moon threshold and the
|
||||
* day phase all fade (C4). A freshly created element takes its final state at
|
||||
* once: the first appearance after a page or chunk load is not animated.
|
||||
@@ -120,3 +131,15 @@ export function renderMoon(
|
||||
<use href="#hp-moon-art" mask="url(#hp-moon-phase)"></use>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #718 K3/K4: the moon without an environment — a scene-sized layer, first
|
||||
* child of the scene, fading with the View weight of the #101 transition as
|
||||
* the environment does. Inside, the very element of the environment.
|
||||
*/
|
||||
export function renderMoonSky(
|
||||
host: MoonHost, settings: unknown, phase: DayCyclePhase, viewWeight: number, now: Date = new Date(),
|
||||
): TemplateResult {
|
||||
return html`<div class="hp-moon-sky" aria-hidden="true"
|
||||
style="opacity:${Math.min(1, Math.max(0, viewWeight)).toFixed(4)}">${renderMoon(host, settings, phase, now)}</div>`;
|
||||
}
|
||||
|
||||
+66
-8
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* The moon over the "Follow the Sun" background (#661): pure astronomy,
|
||||
* visibility rules and the phase mask. No DOM, no Lit — the lazy
|
||||
* `moon-runtime` chunk renders from these, unit tests call them directly.
|
||||
* 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
|
||||
@@ -9,7 +10,7 @@
|
||||
* 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 } from './sun';
|
||||
import type { DayCyclePhase, DayCycleSource } from './sun';
|
||||
|
||||
/**
|
||||
* C1: the same 3° as the window rays (`RAY_ELEVATION_MIN`), so both lights
|
||||
@@ -112,10 +113,9 @@ export interface MoonView {
|
||||
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
/**
|
||||
* C1 for an existing environment. The environment itself is the gate for the
|
||||
* rest: it exists only on a View surface whose effective background follows
|
||||
* the sun (`bg_mode` per space, then global). `settings` are the global
|
||||
* settings, `config` is `hass.config`.
|
||||
* 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);
|
||||
@@ -126,6 +126,64 @@ export function moonView(settings: unknown, phase: DayCyclePhase, config: unknow
|
||||
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)}`;
|
||||
|
||||
+14
-9
@@ -37,8 +37,9 @@ import {
|
||||
type PageVisibilitySignal,
|
||||
} from './visual-continuity';
|
||||
import {
|
||||
bgModeOf, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
|
||||
bgModeOf, resolveDayCycle, type DayCycleState,
|
||||
} from './sun';
|
||||
import { dayCycleClock, moonSkyState } from './moon-gate';
|
||||
import { PointerModalityController } from './pointer-modality';
|
||||
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
|
||||
import {
|
||||
@@ -167,31 +168,35 @@ class HouseplanSpaceCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _dayCycleState(now: Date | number = new Date()): DayCycleState | null {
|
||||
/**
|
||||
* The clock ticker's sample: the environment over "Follow the Sun", else the
|
||||
* moon's sky over a static background — compared by its phase only (#718 K5).
|
||||
*/
|
||||
private _dayCycleClock(now: Date | number = new Date()): [DayCycleState | null, string] {
|
||||
const cfg = this._snap?.config;
|
||||
if (!cfg || !this._config) return null;
|
||||
if (!cfg || !this._config) return [null, ''];
|
||||
const spaceSettings = cfg.spaces?.find((space: any) => space.id === this._config?.space)?.settings || {};
|
||||
if (bgModeOf(cfg.settings, spaceSettings) !== 'daynight') return null;
|
||||
return resolveDayCycle(this._renderDeviceSnapshot?.hass || this.hass, now);
|
||||
const daynight = bgModeOf(cfg.settings, spaceSettings) === 'daynight';
|
||||
const hass = this._renderDeviceSnapshot?.hass || this.hass;
|
||||
return dayCycleClock(daynight ? resolveDayCycle(hass, now) : null, moonSkyState(cfg.settings, daynight, 1, hass, now));
|
||||
}
|
||||
|
||||
private _dayCycleTick = (): void => {
|
||||
if (!this.isConnected || this.ownerDocument.visibilityState === 'hidden') return;
|
||||
const state = this._dayCycleState();
|
||||
const [state, key] = this._dayCycleClock();
|
||||
if (!state) {
|
||||
if (this._dayCycleTimer) { window.clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; }
|
||||
this._dayCycleClockKey = '';
|
||||
return;
|
||||
}
|
||||
const key = dayCycleFingerprint(state);
|
||||
if (key === this._dayCycleClockKey) return;
|
||||
this._dayCycleClockKey = key;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private _syncDayCycleClock(): void {
|
||||
const state = this._dayCycleState();
|
||||
this._dayCycleClockKey = state ? dayCycleFingerprint(state) : '';
|
||||
const [state, key] = this._dayCycleClock();
|
||||
this._dayCycleClockKey = key;
|
||||
const needsTimer = state?.source === 'clock'
|
||||
&& this.ownerDocument.visibilityState !== 'hidden' && this.isConnected;
|
||||
if (needsTimer && !this._dayCycleTimer) {
|
||||
|
||||
+7
-3
@@ -25,7 +25,7 @@ import {
|
||||
import { DEFAULT_ICON_RULES, compileIconRules } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, resolveDayCycle } from './sun';
|
||||
import { moonLayer, type MoonHost } from './moon-gate';
|
||||
import { moonLayer, moonSkyState, type MoonHost } from './moon-gate';
|
||||
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
|
||||
import type { DevItem, OpeningCfg, ServerConfig } from './types';
|
||||
import { floorMinusBodies, physicalBodyParts, polyclipPathD } from './physical-geometry';
|
||||
@@ -670,9 +670,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
});
|
||||
// The static card paints the same four-phase environment as full View.
|
||||
// Wedges stay full-card-only; the decorative background is independent.
|
||||
// #718: a static background gets the moon in its own sky layer instead.
|
||||
const spaceSettings = (o.cfg.spaces.find((sp: any) => sp.id === o.spaceId) as any)?.settings || {};
|
||||
const dayCycle = bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight'
|
||||
? resolveDayCycle(planHass, o.dayCycleNow ?? new Date()) : null;
|
||||
const daynight = bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight';
|
||||
const dayCycleNow = o.dayCycleNow ?? new Date();
|
||||
const dayCycle = daynight ? resolveDayCycle(planHass, dayCycleNow) : null;
|
||||
const moonSky = moonSkyState(o.cfg?.settings, daynight, 1, planHass, dayCycleNow);
|
||||
const stageBg = stageBgOf(o.cfg?.settings, disp);
|
||||
|
||||
// Opaque plan paper, same contract as the full card (docs/DECOR-EDITOR.md §3.3):
|
||||
@@ -931,6 +934,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
?inert=${!!o.inert}
|
||||
style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--hp-cell-visual-scale:${gridVisualScale(cellCm)};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}">
|
||||
${renderDayCycleEnvironment(dayCycle, 1, moonLayer(o.moonHost, o.cfg?.settings, dayCycle))}
|
||||
${moonLayer(o.moonHost, o.cfg?.settings, moonSky, 1)}
|
||||
${dayCycle && paperShapes.length ? svg`<svg class="hp-paper-outline-svg"
|
||||
viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}"
|
||||
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
|
||||
|
||||
Reference in New Issue
Block a user