From c8c470ed57ad2ed61389a82a8e78c032a15870bb Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 30 Sep 2026 12:25:46 +0300 Subject: [PATCH] feat(view): the moon in its phase over the "Follow the Sun" background (#661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At dawn, dusk and night the environment shows the moon in the top-left corner of the scene, behind the plan: computed in the card from the home coordinates and the browser clock (short Meeus series + topocentric parallax, within 1.4° / 1.8 pp of JPL Horizons), one designer image under a continuous phase mask with the lit side always on the left (owner 2026-09-29), a feathered terminator, 3°/3 % thresholds and the 2 s fade of the window rays. Everything but a small gate lives in the lazy moon-runtime chunk, with its own 30 s ticker. General settings: "Sun" becomes "Sun and Moon" with one switch, on for new installations (DEFAULT_CONFIG), off for existing ones. The initial View graph sat 728 B under its budget: the gate is paid for by moving fifteen dialog-only strings of General settings into the lazy settings dictionary (#459) and by one build fingerprint literal instead of three, so the graph ends 4 B above dev. Golden: two new moon scenes, and the two General settings help frames show «Sun and Moon»; the WSL artifact test fixture now models scenes whose first capture awaits acceptance. Issue: #661 User-Visible: yes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- assets/moon/houseplan-1.0.0/LICENSE.md | 21 ++ assets/moon/houseplan-1.0.0/README.md | 47 ++++ assets/moon/houseplan-1.0.0/moon.svg | 1 + assets/moon/houseplan-1.0.0/pack.json | 38 +++ custom_components/houseplan/const.py | 4 +- .../houseplan/support_package.py | 3 + custom_components/houseplan/validation.py | 2 + demo/golden/harness.mjs | 33 ++- demo/golden/matrix.mjs | 15 +- demo/smoke_daycycle_layer_budget.mjs | 38 +++ demo/smoke_general_settings.mjs | 2 +- demo/smoke_moon.mjs | 146 ++++++++++ docs/CHANGELOG.md | 7 + docs/CHANGELOG.ru.md | 7 + docs/CONFIG-COMPATIBILITY.md | 18 ++ docs/SUN.md | 92 +++++++ docs/USER-GUIDE.md | 31 ++- docs/USER-GUIDE.ru.md | 33 ++- package.json | 2 + scripts/bundle-budget.mjs | 9 + scripts/bundle-manifest.mjs | 23 +- scripts/check-inputs.mjs | 2 +- scripts/config-schema.json | 4 + scripts/generate-moon-assets.mjs | 99 +++++++ scripts/monolith-baseline.json | 2 +- scripts/mutation-registry.mjs | 108 +++++++- scripts/smoke-links.mjs | 11 + src/day-cycle-render.ts | 9 +- src/editor-runtime-loader.ts | 3 + src/editors/general-form-state.ts | 11 + src/editors/general-settings-dialog.ts | 43 +-- src/furniture-art-runtime.ts | 2 +- src/houseplan-card.ts | 9 +- src/houseplan-editor-runtime.ts | 9 +- src/i18n/de.json | 17 +- src/i18n/en.json | 17 +- src/i18n/fr.json | 17 +- src/i18n/registry.ts | 2 +- src/i18n/ru.json | 17 +- src/i18n/settings/de.json | 19 +- src/i18n/settings/en.json | 19 +- src/i18n/settings/fr.json | 19 +- src/i18n/settings/ru.json | 19 +- src/moon-art.generated.ts | 8 + src/moon-gate.ts | 50 ++++ src/moon-runtime.ts | 122 +++++++++ src/moon.ts | 156 +++++++++++ src/space-card.ts | 1 + src/space-render.ts | 5 +- src/types.ts | 2 + test/bundle-assets.test.mjs | 32 ++- test/golden-matrix.test.mjs | 22 +- test/golden-wsl-artifact.test.mjs | 16 +- test/moon-settings.test.mjs | 53 ++++ test/moon.test.mjs | 253 ++++++++++++++++++ tests_backend/test_ha_import_export.py | 15 ++ tests_backend/test_settings_moon.py | 41 +++ tsconfig.test.json | 2 +- 58 files changed, 1682 insertions(+), 126 deletions(-) create mode 100644 assets/moon/houseplan-1.0.0/LICENSE.md create mode 100644 assets/moon/houseplan-1.0.0/README.md create mode 100644 assets/moon/houseplan-1.0.0/moon.svg create mode 100644 assets/moon/houseplan-1.0.0/pack.json create mode 100644 demo/smoke_moon.mjs create mode 100644 scripts/generate-moon-assets.mjs create mode 100644 src/moon-art.generated.ts create mode 100644 src/moon-gate.ts create mode 100644 src/moon-runtime.ts create mode 100644 src/moon.ts create mode 100644 test/moon-settings.test.mjs create mode 100644 test/moon.test.mjs create mode 100644 tests_backend/test_settings_moon.py diff --git a/assets/moon/houseplan-1.0.0/LICENSE.md b/assets/moon/houseplan-1.0.0/LICENSE.md new file mode 100644 index 00000000..df61c8db --- /dev/null +++ b/assets/moon/houseplan-1.0.0/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB (justbusiness) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/moon/houseplan-1.0.0/README.md b/assets/moon/houseplan-1.0.0/README.md new file mode 100644 index 00000000..52363c7e --- /dev/null +++ b/assets/moon/houseplan-1.0.0/README.md @@ -0,0 +1,47 @@ +# House Plan moon pack 1.0.0 + +Designer asset for [issue #661](https://github.com/Matysh/houseplan-card/issues/661) +(moon in its current phase on the "Follow the Sun" background), variant A: +one full-moon image, the phase is applied by the card as an SVG mask (C5). + +- `moon.svg`: square `viewBox="0 0 512 512"`, disc centred at (256, 256), + diameter 480 (16 px margin for anti-aliasing). Full moon, front-lit: no + terminator, no shadow, no halo or glow outside the disc, no baked phase. + Near side, north up, Mare Crisium on the right; the art is never mirrored. + Owner 2026-09-29: the lit side is always the left one (as in the designer's + phase sequence), waning runs the same states backwards; only the mask flips. + Owner 2026-09-30: a distinct state for every day of the lunar month; the mask + is continuous, so the card simply uses the daily illumination k. + The dark side is not drawn: the card renders the same art at 8 % opacity. +- Edge: one radial gradient ring with no hard outer edge (as in the designer's + Figma export): bright rim 0.855-0.90 R, dark outline `#2b3138` at 50 % from + 0.905 R to 0.92 R (owner 2026-09-29, for a forced white background), then a + 1.5-unit fade to transparent by 0.935 R (r 239.4 in the 512 box, inside the 480 + disc). The soft limb hides any missing anti-aliasing of the outer edge. +- The disc is masked (``), not clipped: Chrome with GPU rasterisation draws + `clip-path` edges without anti-aliasing (seen on Windows), masks are anti-aliased. +- Art by JB (Figma, `House-plan` file). The Figma export (`source/figma-export/Full.svg` + in the source archive) was normalised for the pack by `source/convert_jb.py`: + viewBox 200 -> 512 (scale 2.56, disc clipped at r 240), the `mix-blend-mode` + screen ring and multiply groups removed (blend modes depend on the stacking + context they land in, so the file now composites normally and looks the same + everywhere), the three 5-7 % crater layers and the 484-path speckle texture + dropped (invisible at 200 px), and each mare "blend" of 22 nested rings + thinned to 4 rings (steps 0, 8, 16, 21). The export's screen-blended edge ring is rebuilt + as a normal-composited radial gradient (the art is masked at r 230), so the feathered edge is part + of the 480 disc and nothing is drawn outside it. Path coordinates rounded to 0.1, gradient offsets kept at 0.001 (`svgo.config.mjs`). +- Vector only: two `radialGradient`s (disc, edge), one `mask`, paths. No `filter`, + `feGaussianBlur`, `feImage`, embedded raster, fonts or external references. + 25.6 KB raw, about 9 KB gzip. +- Colours: disc `#949493` (centre) -> `#D6D5D4` (rim), maria grey rings + `#404040 -> white` under a 0.2-0.6 group opacity. Checked on the night + (`#111a27 -> #1f2f3e`), dusk (`#48536c -> #9a7380`) and dawn + (`#aabdd1 -> #e8c8b7`) palettes at 200, 120 and 94 px. +- `pack.json`: pack id, version, licence, source link and the disc geometry + for `scripts/generate-moon-assets.mjs`. + +JB (justbusiness) supplies this asset for House Plan to use, modify and +distribute under the repository MIT License without separate UI attribution +(`LICENSE.md`). The reviewed source archive with its SHA-256 is attached to +issue #661. Generated TypeScript (`src/moon-art.generated.ts`) must not be +edited by hand; regenerate it from this pack. diff --git a/assets/moon/houseplan-1.0.0/moon.svg b/assets/moon/houseplan-1.0.0/moon.svg new file mode 100644 index 00000000..dd0eaee0 --- /dev/null +++ b/assets/moon/houseplan-1.0.0/moon.svg @@ -0,0 +1 @@ +House Plan moon 1.0.0: full disc, front-lit (issue #661), art by JB \ No newline at end of file diff --git a/assets/moon/houseplan-1.0.0/pack.json b/assets/moon/houseplan-1.0.0/pack.json new file mode 100644 index 00000000..1c61ff29 --- /dev/null +++ b/assets/moon/houseplan-1.0.0/pack.json @@ -0,0 +1,38 @@ +{ + "schema_version": 1, + "pack_id": "houseplan", + "pack_version": "1.0.0", + "title_ru": "Луна: полный диск для маски фазы", + "title_en": "Moon: full disc for the phase mask", + "author": "JB (justbusiness)", + "license": "MIT", + "source_url": "https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan", + "issue": 661, + "file": "moon.svg", + "view_box": [ + 0, + 0, + 512, + 512 + ], + "disc": { + "cx": 256, + "cy": 256, + "r": 240 + }, + "lit": "front", + "north_up": true, + "east_right": true, + "notes_en": "Full moon, front-lit, no terminator, no shadow, no glow outside the disc; thin dark outline for light backgrounds. Art by JB (Figma export normalised: viewBox 512, blend modes removed, rings thinned). Phase mask and the 8 % dark side are applied by the card (C5). The art is never mirrored; the lit side is always the left one, waning runs the same states backwards (owner 2026-09-29).", + "notes_ru": "Полная луна, освещение в лоб, без терминатора, тени и ореола; тонкая тёмная обводка для светлого фона. Арт JB (экспорт из Figma нормализован: viewBox 512, без blend-режимов, кольца морей прорежены). Маску фазы и тёмную сторону 8 % накладывает карточка (C5). Арт не зеркалится; освещённая сторона всегда слева, убывание идёт теми же состояниями в обратном порядке (владелец 29.09).", + "mirror": false, + "outline": { + "color": "#2b3138", + "opacity": 0.5, + "from_r": 0.905, + "to_r": 0.92, + "fade_to_r": 0.935, + "note": "part of the edge gradient, no separate stroke" + }, + "lit_side": "left" +} diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 970bd659..106d393a 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -91,7 +91,9 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3 DEFAULT_CONFIG: dict[str, object] = { "spaces": [], "markers": [], - "settings": {"bg_mode": "daynight", "sun_ray_origin": "inner"}, + # #661: new installations get the moon; an existing config without the key + # keeps its look (docs/SUN.md: an update never changes a stored plan). + "settings": {"bg_mode": "daynight", "sun_ray_origin": "inner", "moon": True}, } # #42: THE stable public error-code contract. Every code a user-facing diff --git a/custom_components/houseplan/support_package.py b/custom_components/houseplan/support_package.py index c7c04557..094f027f 100644 --- a/custom_components/houseplan/support_package.py +++ b/custom_components/houseplan/support_package.py @@ -158,6 +158,9 @@ def _global_settings(value: object) -> dict[str, Any]: volumetric_view = value.get("volumetric_view") if isinstance(volumetric_view, bool): out["volumetric_view"] = volumetric_view + moon = value.get("moon") + if isinstance(moon, bool): + out["moon"] = moon fill_colors = value.get("fill_colors") if isinstance(fill_colors, dict): palette = { diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index c25119ff..3356f3e0 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -2313,6 +2313,8 @@ CONFIG_SCHEMA = vol.All( vol.Optional("show_room_tooltip"): bool, # #649: installation-wide 2.5D View; absent/false keeps Flat. vol.Optional("volumetric_view"): bool, + # #661: the moon on the "Follow the Sun" background; absent/false = off. + vol.Optional("moon"): bool, vol.Optional("summary_panel"): SUMMARY_PANEL_WIRE_SCHEMA, vol.Optional("radar"): vol.Schema({}, extra=vol.ALLOW_EXTRA), # Removed from the UI/runtime in 2026-08-08. Keep accepting the diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index 7cd36ebf..35833db5 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -83,7 +83,22 @@ async function stableEnvironment(page, scenario) { }); await page.evaluate(({ variables, panelVariableNames, theme, panelHost, attemptedTarget, attemptKey, disableIsoFilters, + moonClock, }) => { + // #661: a moon scene reads the browser clock. The scenario shifts it to + // its instant (time keeps flowing, so the card's timers behave); every + // other scenario gets the native clock back, like CSS.supports below. + window.__hpGoldenNativeDate ||= Date; + const NativeDate = window.__hpGoldenNativeDate; + if (moonClock) { + const offset = Date.parse(moonClock) - NativeDate.now(); + window.Date = class GoldenDate extends NativeDate { + constructor(...args) { super(...(args.length ? args : [NativeDate.now() + offset])); } + static now() { return NativeDate.now() + offset; } + }; + } else { + window.Date = NativeDate; + } window.__hpGoldenNativeCssSupports ||= CSS.supports.bind(CSS); CSS.supports = disableIsoFilters ? ((property, value) => property === 'filter' @@ -138,6 +153,7 @@ async function stableEnvironment(page, scenario) { ? String(scenario.integrationVersion || '').trim() : null, attemptKey: VERSION_RELOAD_ATTEMPT_KEY, disableIsoFilters: scenario.disableIsoFilters === true, + moonClock: scenario.moon?.clock || null, }); } @@ -687,6 +703,9 @@ export function prepareGoldenFixture(scenario) { ...(scenario.customFill ? { custom_fill: scenario.customFill } : {}), }; } + // #661: the moon is an installation-wide switch; the scene's home and clock + // arrive through hass.config and the page clock (stableEnvironment). + if (scenario.moon) fixture.config.settings = { ...(fixture.config.settings || {}), moon: true }; // #577 is deliberately global-only. Keeping this outside the per-space // scenario overrides makes the golden exercise the same read path as a real // General settings save instead of silently falling back to legacy inner. @@ -988,7 +1007,10 @@ export async function prepareGoldenScenario(page, scenario) { connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined }, localize: () => null, formatEntityState: (state) => state.state, - config: { unit_system: { length: 'km' } }, + config: { + unit_system: { length: 'km' }, + ...(scenario.moon ? { latitude: scenario.moon.latitude, longitude: scenario.moon.longitude } : {}), + }, }); const mount = async () => { let card; @@ -2181,6 +2203,15 @@ export async function prepareGoldenScenario(page, scenario) { if (!marker) throw new Error(`golden focus device missing: ${scenario.focusDevice}`); marker.focus({ focusVisible: true }); } + if (scenario.moon) { + // The moon chunk is lazy: wait for it, then for the expected phase. + await until(() => card.renderRoot.querySelector('.hp-moon')?.dataset.moonVisible === 'true'); + const moon = card.renderRoot.querySelector('.hp-moon'); + if (moon.dataset.moonK !== scenario.moon.k || moon.parentElement !== card.renderRoot.querySelector('.hp-day-cycle-env')) { + throw new Error(`golden moon contract did not render: ${scenario.id} (k ${moon.dataset.moonK})`); + } + await frame(); + } if (scenario.dayCycle) { const environment = card.renderRoot.querySelector('.hp-day-cycle-env'); const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active'); diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 26746674..8e2a84d5 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -1,7 +1,7 @@ import { fixtureWallKey } from '../fixtures/visual-matrix.mjs'; /** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */ -export const GOLDEN_MATRIX_VERSION = 67; +export const GOLDEN_MATRIX_VERSION = 68; const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } }; const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } }; @@ -549,6 +549,19 @@ export const GOLDEN_SCENARIOS = Object.freeze([ bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' }, stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } }, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage }, + // #661 AC7: the moon in its phase at the top-left of the scene, behind the + // plan — the plan deliberately covers part of the disc (owner decision 7). + // The lit side is the left one in both hemispheres (owner 2026-09-29). + { id: 'day-cycle-night-moon-gibbous-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view', + bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' }, + moon: { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' }, + stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } }, + theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage }, + { id: 'day-cycle-dusk-moon-crescent-south-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view', + bgMode: 'daynight', dayCycle: { phase: 'dusk', top: '#48536c' }, + moon: { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' }, + stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } }, + theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage }, { id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan', theme: 'dark', viewport: { width: 1180, height: 900 }, ...page }, { id: 'safe-resize-handles-clamp-light', fixture: 'visual', space: 'golden-safe-resize', diff --git a/demo/smoke_daycycle_layer_budget.mjs b/demo/smoke_daycycle_layer_budget.mjs index f304a682..3edc55b2 100644 --- a/demo/smoke_daycycle_layer_budget.mjs +++ b/demo/smoke_daycycle_layer_budget.mjs @@ -199,6 +199,40 @@ const frameMetrics = await page.evaluate(async ({ encoded, stage }) => { }), }); +// #661 AC6: at night, switching the moon on adds no composited layer — one +// element inside the environment, no CSS filter, no will-change (C8). The +// comparison is night without the moon against night with it, both settled. +await page.clock.setFixedTime(new Date('2026-10-21T18:00:00Z')); +await page.evaluate(async () => { + const card = window.__card; + card.hass = { + ...card.hass, + config: { ...(card.hass.config || {}), latitude: 55.75, longitude: 37.62 }, + states: { ...card.hass.states, 'sun.sun': { + entity_id: 'sun.sun', state: 'below_horizon', + attributes: { azimuth: 0, elevation: -12, rising: false }, + } }, + }; + await card.updateComplete; +}); +await settle(); +// Transitions promote layers while they run: compare only settled frames. +const transitionsDone = () => page.waitForFunction( + () => !window.__card.renderRoot.getAnimations().some((animation) => animation instanceof CSSTransition), + null, { timeout: 10000 }, +); +await transitionsDone(); +const nightLayers = (await layerSnapshot()).layers; +await page.evaluate(async () => { + const card = window.__card; + card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, moon: true } }; // private-ok: the synthetic 1 cm/point space exists only in the card config, a server push would reload the demo plan + card.requestUpdate(); + await card.updateComplete; +}); +await page.waitForFunction(() => !!window.__card.renderRoot.querySelector('.hp-moon.on'), null, { timeout: 8000 }); +await transitionsDone(); +const moonLayers = (await layerSnapshot()).layers; + // Contract item 6: the non-interactive houseplan-space-card never needs a // gesture to become safe. Its day-cycle silhouette is stage-bounded from its // first frame, while the visible paper group owns no filter layer. @@ -311,6 +345,8 @@ checks.staticCardOutlineLayerIsStageBounded = !!staticOutlineLayer && staticOutlineLayer.width <= staticStage.width * 1.5 + 64 && staticOutlineLayer.height <= staticStage.height * 1.5 + 64; checks.staticCardHasNo4096ContentLayer = staticOversized.length === 0; +checks.moonAddsNoCompositedLayer = moonLayers.length === nightLayers.length + && !moonLayers.some((layer) => layer.className.includes('hp-moon')); console.log(JSON.stringify({ stage: active.stage, @@ -324,6 +360,8 @@ console.log(JSON.stringify({ staticStage, staticLayers, staticOversized, + nightLayers: nightLayers.length, + moonLayers: moonLayers.length, }, null, 2)); checkAll(checks); await cdp.send('LayerTree.disable'); diff --git a/demo/smoke_general_settings.mjs b/demo/smoke_general_settings.mjs index 40cc37ea..7731e95b 100644 --- a/demo/smoke_general_settings.mjs +++ b/demo/smoke_general_settings.mjs @@ -83,7 +83,7 @@ const res = await page.evaluate(async () => { checkAll(res, { "rows": 15, // 10 плиток + плашка стены + радиус свечения + фон + грань окна #577 // + «Оптимизировать планы» по действию, а не удалённому #605 классу alignall - "cards": ["Display", "Zigbee links", "Room fill colors", "Light-source glow", "Plan", "Sun", "Data"], + "cards": ["Display", "Zigbee links", "Room fill colors", "Light-source glow", "Plan", "Sun and Moon", "Data"], "groups": ["Lights", "Temperature", "Zigbee signal", "Backup and transfer", "Plan maintenance"], "everyCardHasContent": true, "sunMissingStaysVisible": true, diff --git a/demo/smoke_moon.mjs b/demo/smoke_moon.mjs new file mode 100644 index 00000000..e175adcf --- /dev/null +++ b/demo/smoke_moon.mjs @@ -0,0 +1,146 @@ +// #661 AC4/AC5: the moon over the "Follow the Sun" background in a real +// browser. The page clock is Playwright's (`page.clock`), the home is Moscow +// through `hass.config`, the environment phase comes from `sun.sun`; the moon +// is switched on the way another client's save arrives (`__hpTest`). +// +// Moscow 2026-10-21 (waxing, 77 %): the moon sets through 3° between 22:37Z +// (3.03°) and 22:38Z (2.90°) — 22:30Z is above, 22:45Z below. +import { launch, checkAll, finish } from './serve.mjs'; + +const ABOVE = new Date('2026-10-21T22:30:00Z'); +const BELOW = new Date('2026-10-21T22:45:00Z'); + +const { page, browser } = await launch({ width: 1000, height: 760 }); +await page.clock.setFixedTime(ABOVE); + +/** A state update, as Home Assistant pushes one: new hass object, same card. */ +const pushHass = (patch) => page.evaluate(async ({ sun, config }) => { + const card = window.__card; + card.hass = { + ...card.hass, + ...(config ? { config: { ...(card.hass.config || {}), ...config } } : {}), + states: sun ? { ...card.hass.states, 'sun.sun': { entity_id: 'sun.sun', state: 'below_horizon', attributes: sun } } : card.hass.states, + }; + await card.updateComplete; +}, patch); +const moonState = () => page.evaluate(() => { + const root = window.__card.renderRoot; + const moon = root.querySelector('.hp-moon'); + if (!moon) return null; + const style = getComputedStyle(moon); + return { + on: moon.classList.contains('on'), + opacity: Number(style.opacity), + duration: style.transitionDuration, + parent: moon.parentElement?.className || '', + phase: root.querySelector('.hp-day-cycle-env')?.dataset.dayCyclePhase || null, + count: root.querySelectorAll('.hp-moon').length, + }; +}); +const waitMoon = (predicate, what) => page.waitForFunction(predicate, null, { timeout: 8000 }) + .catch(() => { throw new Error(`moon did not ${what}`); }); + +await pushHass({ config: { latitude: 55.75, longitude: 37.62 }, sun: { azimuth: 0, elevation: -12, rising: false } }); +await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => { + cfg.settings = { ...(cfg.settings || {}), bg_mode: 'daynight', moon: true }; + for (const space of cfg.spaces) if (space.settings) delete space.settings.bg_mode; + return cfg; +})); +await waitMoon(() => !!window.__card.renderRoot.querySelector('.hp-moon'), 'appear after the chunk loaded'); +const first = await moonState(); + +const checks = {}; +// C4: the first appearance after the chunk load is not animated. +checks.firstAppearanceIsImmediate = first?.on === true && first.opacity === 1; +checks.oneMoonInsideTheEnvironment = first?.count === 1 && first.parent === 'hp-day-cycle-env'; +checks.twoSecondFade = first?.duration === '2s'; + +// AC4 in the browser: three 30 s ticks over an unchanged sky touch nothing. +await page.evaluate(() => { + const moon = window.__card.renderRoot.querySelector('.hp-moon'); + window.__hpMoonMutations = 0; + window.__hpMoonObserver = new MutationObserver((records) => { window.__hpMoonMutations += records.length; }); + window.__hpMoonObserver.observe(moon, { attributes: true, childList: true, subtree: true, characterData: true }); +}); +for (let tick = 0; tick < 3; tick++) await page.clock.runFor(30_000); +checks.threeQuietTicksNoRender = await page.evaluate(() => window.__hpMoonMutations) === 0; + +// AC5: the ticker alone carries the moon through 3° — it fades out over 2 s. +await page.clock.setFixedTime(BELOW); +await page.clock.runFor(30_000); +await waitMoon(() => !window.__card.renderRoot.querySelector('.hp-moon')?.classList.contains('on'), 'set by the ticker'); +await page.waitForTimeout(250); +const fading = await moonState(); +checks.setsWithAFade = fading.on === false && fading.opacity > 0 && fading.opacity < 1; +await page.waitForTimeout(2200); +checks.setsToZero = (await moonState()).opacity === 0; +await page.evaluate(() => window.__hpMoonObserver.disconnect()); + +// Back above 3° on the next render (a state update): fades in to 1. +await page.clock.setFixedTime(ABOVE); +await pushHass({ sun: { azimuth: 2, elevation: -12.1, rising: false } }); +await page.waitForTimeout(250); +const rising = await moonState(); +checks.risesWithAFade = rising.on === true && rising.opacity > 0 && rising.opacity < 1; +await page.waitForTimeout(2200); +checks.risesToOne = (await moonState()).opacity === 1; + +// C6 / owner decision 7: the plan paints over the moon. Along the disc's +// diameter, find one point where plan content is hit and one where only the +// empty scene is; then compare the painted pixel with the moon shown and +// hidden. Under the plan it must not change, in the open it must. +const probe = await page.evaluate(() => { + const root = window.__card.renderRoot; + const moon = root.querySelector('.hp-moon'); + const box = moon.getBoundingClientRect(); + const cy = Math.round(box.top + box.height / 2); + const disc = box.width * 0.47; + let covered = null; + let open = null; + for (let x = box.left + box.width / 2 - disc * 0.9; x <= box.left + box.width / 2 + disc * 0.9; x += 2) { + const hit = root.elementFromPoint(x, cy); + const svg = hit?.closest('svg.plan-svg'); + if (svg && hit !== svg) covered ??= Math.round(x); + else if (!svg || hit === svg) open ??= Math.round(x); + } + return { covered, open, cy, pointerEvents: getComputedStyle(moon).pointerEvents }; +}); +/** One painted pixel, as PNG bytes (the encoder is deterministic). */ +const pixel = async (x, y) => (await page.screenshot({ clip: { x, y, width: 1, height: 1 } })).toString('base64'); +const withMoonHidden = async (fn) => { + await page.evaluate(() => { + const style = document.createElement('style'); + style.id = 'hp-moon-probe'; + style.textContent = '.hp-moon{visibility:hidden!important}'; + window.__card.renderRoot.appendChild(style); + }); + try { return await fn(); } finally { + await page.evaluate(() => window.__card.renderRoot.querySelector('#hp-moon-probe')?.remove()); + } +}; +const shown = { + covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy), + open: probe.open === null ? null : await pixel(probe.open, probe.cy), +}; +const hidden = await withMoonHidden(async () => ({ + covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy), + open: probe.open === null ? null : await pixel(probe.open, probe.cy), +})); +checks.planCoversPartOfTheDisc = probe.covered !== null && probe.open !== null; +checks.moonChangesNoPixelUnderThePlan = shown.covered !== null && shown.covered === hidden.covered; +checks.moonIsPaintedWhereThePlanIsNot = shown.open !== null && shown.open !== hidden.open; +checks.moonIgnoresThePointer = probe.pointerEvents === 'none'; + +// Daytime: the environment turns to day and the moon fades out. +await pushHass({ sun: { azimuth: 180, elevation: 40, rising: false } }); +await page.waitForTimeout(2300); +const day = await moonState(); +checks.dayHidesTheMoon = day?.phase === 'day' && day.on === false && day.opacity === 0; + +// Reduced motion: no transition at all. +await page.emulateMedia({ reducedMotion: 'reduce' }); +checks.reducedMotionNoTransition = (await moonState()).duration === '0s'; + +console.log(JSON.stringify({ first, fading, rising, day, probe }, null, 2)); +checkAll(checks); +await finish(browser, checks); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9ebd338e..333a7dad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- On the "Follow the Sun" background, the moon in its current phase now appears + in the top-left corner of the scene at dawn, dusk and night, behind the plan. + It follows the real moon over your home (Home Assistant home location), fades + in and out as it crosses 3° above the horizon, and is hidden by day and around + new moon. General settings: the "Sun" section is now "Sun and Moon", with a + new switch that is on for new installations + ([#661](https://github.com/Matysh/houseplan-card/issues/661)). - In 2.5D View, device icons no longer shift when a device changes state (for example when a light turns on and shows its brightness); a Home Assistant update in a dense plan also no longer re-lays out every icon diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 01683c17..59dfe9be 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,13 @@ ## Не выпущено +- На фоне «Следует за Солнцем» в сумерках и ночью в левом верхнем углу сцены, + за планом, теперь видна луна в текущей фазе. Она следует настоящей луне над + вашим домом (по координатам дома из Home Assistant), плавно появляется и + исчезает при пересечении 3° над горизонтом, днём и в новолуние её нет. В общих + настройках раздел «Солнце» стал «Солнце и Луна», в нём новый переключатель, + включённый у новых установок + ([#661](https://github.com/Matysh/houseplan-card/issues/661)). - В объёмном 2.5D-виде значки устройств больше не сдвигаются, когда меняется состояние устройства (например, лампа включилась и показала яркость); обновление Home Assistant в плотном плане больше не переразмещает все значки diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index f2aae7c7..10b175c4 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -134,6 +134,24 @@ An older frontend ignores the field and shows Flat. An older backend preserves it through the unknown-settings policy. Full backup/import carries `settings` whole, so the value survives. +## Moon (#661) + +`settings.moon` is an optional global boolean: the moon on the "Follow the Sun" +background for every space, user, device and kiosk. Only exact `true` switches +it on; absence, `false` or anything else read as off. Saving `false` removes +the key. There is no per-space moon: a space shows it only when its effective +`bg_mode` is `daynight`. The backend accepts only a boolean +(`vol.Optional("moon"): bool`), and the privacy-safe support projection copies +only a validated boolean. New installations get `moon: true` in +`DEFAULT_CONFIG`; an existing config has no key and keeps its look after the +update (docs/SUN.md). "Reset to defaults" in General settings sets `true`. The +store and model versions do not change; there is no migration. + +An older frontend ignores the field and shows no moon. An older backend +preserves it through the unknown-settings policy. Full backup/import carries +`settings` whole, so the value survives; a one-space export does not carry +global settings. + ## Stairs (#663) `spaces[].stairs[]` is an optional bounded (250 records per space) diff --git a/docs/SUN.md b/docs/SUN.md index 7a29fe0a..daa47a05 100644 --- a/docs/SUN.md +++ b/docs/SUN.md @@ -376,6 +376,92 @@ The wash lives where the Flat `.sunlayer` lives (floor group, above room fills and Glow) as `.sunlayer.iso-sunwash`, one `.iso-sunbeam[data-opening]` per lit window. Witness: `demo/smoke_iso_sun.mjs`. +## Moon — `settings.moon` (#661) + +At dawn, dusk and night on the "Follow the Sun" background, the moon in its +current phase stands in the top-left corner of the scene: a thin crescent, a +half, a full disc. General settings › Sun and Moon › «Moon over the plan at +dusk and night» switches it for the whole installation. + +**When it is shown** — all at once, otherwise there is no moon: + +- `settings.moon === true` (global; absent, `false` or anything else is off); +- the effective `bg_mode` of the space is `daynight` — the moon lives in the + four-phase environment, so a space with its own `static` has no moon, and + there is no per-space moon switch; +- the environment phase is `dawn`, `dusk` or `night` (the same + `resolveDayCycle`, browser-clock fallback included); +- the topocentric altitude is ≥ 3° (`MOON_ELEVATION_MIN = RAY_ELEVATION_MIN`); +- the illuminated fraction is ≥ 3 % (`MOON_MIN_ILLUMINATION`, about ±1.5 days + around new moon); +- a View surface: View, kiosk or `houseplan-space-card` (editors have no + environment); +- `hass.config.latitude/longitude` are finite numbers. + +**Where the numbers come from.** Home Assistant publishes no moon altitude +(`sun.sun` is the sun; the optional Moon integration gives eight phase names +without altitude, illumination or hemisphere). The card computes both from the +home coordinates and the browser clock (`src/moon.ts`): the short Meeus series +as in SunCalc, the topocentric parallax `h − π·cos h` with +`π = asin(6378.14 / distance)`, no refraction; the illuminated fraction +`k = (1 + cos i) / 2` from the Sun–Moon elongation. Against JPL Horizons +(airless) on twelve points in Moscow and Sydney, October 2026: altitude within +1.39°, illumination within 1.77 percentage points (`test/moon.test.mjs`, the +table and the query parameters are in the test). + +**Phase.** One designer image of the full moon (`assets/moon/houseplan-1.0.0`, +art by JB) under an SVG mask. The lit side is **always the left one**, in both +hemispheres; waning runs the waxing states backwards (owner 2026-09-29). The +lit region is the left half of the box plus or minus the terminator +half-ellipse `R × R·|2k − 1|` (bulging into the dark side when `k > 0.5`); the +half's outer arc runs along the box, never along the limb, so the disc keeps +the art's own anti-aliased edge. The mask is a ``, not a `clipPath` +(Chrome with GPU rasterisation draws clip edges jagged). The terminator is +feathered by a Gaussian blur inside the mask (5 units of 512, about 2 px at +200 px), except for a full disc (`k ≥ 0.995`). The phase is continuous in `k`, +quantised to 0.01 only so the element changes when the fingerprint does. The +dark side is the same art at 8 % opacity. + +**Place, size, layer.** Fixed top-left corner, box `min(200px, 25cqmin)` of the +scene (the environment is the size container), inset 5 % of the box; it does +not move with pan or zoom and never takes the pointer. It is the last child of +`.hp-day-cycle-env`: above the phase gradients and the sun glow, below the plan +paper, rooms, devices, labels and UI. A plan that fills the scene covers the +moon partly or entirely — that is the environment's norm, like the sun glow +(owner decision 7). + +**Movement.** Opacity only, 2 s on the background curve (`RAY_FADE_MS`), +none under `prefers-reduced-motion`: rising through 3°, setting through it, +the new-moon threshold and the day phase all fade; the first appearance after +a page or chunk load does not. The element is recomputed on every render of the +environment and by its own 30 s ticker (the moon rises at most 0.25°/min); +the ticker keeps the last rendered inputs, and an equal fingerprint +(`visible | k`) costs no render. A card that renders without a moon, leaves the +page or is hidden drops or pauses its ticker. + +**Weight.** Astronomy, art (≈ 9 KB gzip) and template are one lazy chunk, +`moon-runtime-*`, loaded when the moon is switched on, the environment exists +and it is not daytime; until it arrives there is no moon, a failed load is +retried at most every 30 s through the exact-build loader of the isometric +runtime. The initial View graph carries only the gate (`src/moon-gate.ts`). +One element, no CSS filter and no `will-change`: the composited layer count +does not change (`demo/smoke_daycycle_layer_budget.mjs`). + +**Limits (documented, not bugs).** + +- Position accuracy ≈ 1°: the moment of crossing 3° may differ from ephemerides + by a few minutes. +- The terminator is not tilted by the parallactic angle, and the lit side does + not follow the hemisphere (owner decision): the crescent is "vertical". +- Earthshine is not modelled; the dark side is an 8 % silhouette for legibility. +- The moon does not follow its azimuth — fixed corner of the scene. +- A dense layout (plan filling the screen) shows only the part of the disc in + the margins. +- Coordinates come from Home Assistant: an installation that kept the default + home location gets someone else's moon, as it gets someone else's `sun.sun`. +- A wrong tablet clock gives a wrong phase and moment — the same class as the + background's clock fallback. + ## Weather independence and legacy `weather_entity` Weather never changes the window rays. Once the feature, compass, @@ -417,6 +503,12 @@ saving General settings removes it. inheritance); unit-tested in `test/sun.test.mjs`. - `src/day-cycle-render.ts` — the shared constant environment layers and plan-outline variables for full and static cards. +- `src/moon.ts`, `src/moon-runtime.ts`, `src/moon-gate.ts`, + `src/moon-art.generated.ts` — the moon (#661): pure astronomy and mask, the + lazy chunk with the element and its ticker, the initial-graph gate, the + generated art (`node scripts/generate-moon-assets.mjs` from + `assets/moon/houseplan-1.0.0`); unit-tested in `test/moon.test.mjs`, + end-to-end in `demo/smoke_moon.mjs`. - `src/houseplan-card.ts` — the memoised wedge layer, four-phase background lifecycle, and both settings dialogs (compass dial included). - `src/space-render.ts` / `src/space-card.ts` — static-card environment and diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 63c9ea61..1d119a6a 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -37,7 +37,7 @@ relay, and exact plan geometry is attached only after you opt in and preview it. 12. [Device visual states](#12-device-visual-states) 13. [Room fills and light](#13-room-fills-and-light) 14. [Background editor](#14-background-editor) -15. [Sun background and window rays](#15-sun-background-and-window-rays) +15. [Sun and Moon: background, window rays and the moon](#15-sun-and-moon-background-window-rays-and-the-moon) 16. [Robot vacuums](#16-robot-vacuums) 17. [Kiosk](#17-kiosk) 18. [Static space card](#18-static-space-card) @@ -1603,7 +1603,7 @@ keeps the same repairable placeholder instead of rejecting the whole plan. ![Selected line in the Background editor](images/07-background-editor.png) -## 15. Sun background and window rays +## 15. Sun and Moon: background, window rays and the moon These are independent features. **Follow the sun** needs no compass: it uses valid `sun.sun` data and otherwise falls back to the browser clock. North and @@ -1651,6 +1651,33 @@ static card share the phase; editors keep their normal background. New installs and spaces default to Follow the sun; upgrades/imports preserve their existing choice. Shadows from trees, awnings or other building wings are not modelled. +### Moon + +On the **Follow the sun** background, at dawn, dusk and night, the moon in its +current phase stands in the top-left corner of the scene — a thin crescent, a +half, a full disc. One switch in General settings turns it on: **Sun and Moon** +→ **Moon over the plan at dusk and night**. It is on for new installations and +off for upgraded ones until switched on: an update never changes how a plan +looks. + +| Condition | Result | +|---|---| +| Daytime, moon below 3° above the horizon, or new moon (under 3 % lit) | No moon | +| The moon rises above 3° or sets below it | Fades in/out over 2 seconds; immediately with reduced motion | +| Phase | Changes continuously, every day; the lit side is always on the left, waning runs the same states backwards | +| Space with its own static background | No moon — it belongs to the Follow the sun background | +| The plan covers the corner of the scene | The moon is behind the plan: the part of the disc in the margins shows; the plan, devices and labels are always on top | +| Editors | No moon | +| Full card, kiosk, static space card | The same | + +Position and phase are computed in the card from the Home Assistant home +location (Settings → System → General) and the device clock, with no +integration or external data. A Home Assistant that kept the default location +shows someone else's moon — as with `sun.sun`; a wrong tablet clock gives a +wrong phase and moment. Position accuracy is about 1°, so crossing 3° may +differ from ephemerides by a few minutes. The terminator tilt and earthshine +are not modelled; the dark side is a faint silhouette so a crescent reads. + ## 16. Robot vacuums Live position needs finite `vacuum_position` or `robot_position` coordinates. diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index fa61f5b1..913abf92 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -38,7 +38,7 @@ Assistant. Эта полноэкранная панель — основной 12. [Визуальные состояния устройств](#12-визуальные-состояния-устройств) 13. [Заливки комнат и свет](#13-заливки-комнат-и-свет) 14. [Редактор подложки](#14-редактор-подложки) -15. [Солнце: фон и оконные лучи](#15-солнце-фон-и-оконные-лучи) +15. [Солнце и Луна: фон, оконные лучи и луна](#15-солнце-и-луна-фон-оконные-лучи-и-луна) 16. [Роботы-пылесосы](#16-роботы-пылесосы) 17. [Киоск-режим](#17-киоск-режим) 18. [Статическая карточка пространства](#18-статическая-карточка-пространства) @@ -1790,9 +1790,9 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со недостающего содержимого и после согласия оставляет ту же восстанавливаемую рамку, а не отклоняет весь план. -## 15. Солнце: фон и оконные лучи +## 15. Солнце и Луна: фон, оконные лучи и луна -Это две независимые функции. Фон **Следует за Солнцем** работает без компаса: +Фон и оконные лучи — две независимые функции; луна живёт на фоне. Фон **Следует за Солнцем** работает без компаса: он использует корректные данные `sun.sun`, а при их отсутствии автоматически переходит на локальные часы браузера. Направление севера и `sun.sun` обязательны только для оконных лучей. Общие значения можно переопределить в конкретном @@ -1850,6 +1850,33 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со Тени от других крыльев здания и внешних объектов не моделируются. +### Луна + +При фоне **Следует за Солнцем** в сумерках (утренних и вечерних) и ночью в левом +верхнем углу сцены видна луна в текущей фазе — тонкий серп, половина, полная. +Включается одним переключателем в общих настройках, раздел **Солнце и Луна** → +**Луна на плане в сумерках и ночью**. У новых установок переключатель включён, +у обновлённых старых — выключен, пока его не включат: обновление не меняет вид +плана. + +| Условие | Результат | +|---|---| +| День, луна ниже 3° над горизонтом или новолуние (освещено меньше 3 %) | Луны нет | +| Луна поднимается выше 3° или уходит ниже | Плавно появляется/исчезает за 2 секунды; при системном уменьшении движения — сразу | +| Фаза | Меняется непрерывно, каждый день; освещённая сторона всегда слева, убывание идёт теми же состояниями в обратном порядке | +| Пространство с собственным статическим фоном | Луны нет — она часть фона «Следует за Солнцем» | +| План закрывает угол сцены | Луна за планом: видна та часть диска, что в полях; план, устройства и подписи всегда поверх неё | +| Редакторы | Луны нет | +| Полная карточка, киоск, статическая карточка пространства | Одинаково | + +Положение и фаза считаются в карточке по координатам дома из Home Assistant +(Настройки → Система → Общие) и часам устройства, без интеграций и внешних +данных. Если координаты дома в Home Assistant не заданы, луна будет «чужой» — +как и данные `sun.sun`; с неверными часами планшета неверны фаза и момент +появления. Точность положения около 1°: момент пересечения 3° может разойтись с +эфемеридами на несколько минут. Наклон терминатора и пепельный свет не +воспроизводятся, тёмная сторона — едва заметный силуэт для читаемости серпа. + ## 16. Роботы-пылесосы Живая позиция доступна для маркера пылесоса, если источник отдаёт конечные diff --git a/package.json b/package.json index e91f95a4..6aced83f 100755 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "benchmark:zigbee-topology": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/benchmark_zigbee_topology.mjs", "furniture:generate": "node scripts/generate-furniture-assets.mjs", "furniture:check": "node scripts/generate-furniture-assets.mjs --check", + "moon:generate": "node scripts/generate-moon-assets.mjs", + "moon:check": "node scripts/generate-moon-assets.mjs --check", "pdf-font:generate": "node scripts/generate-pdf-font.mjs", "pdf-font:check": "node scripts/generate-pdf-font.mjs --check" }, diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index ae1c8821..e6f121be 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -734,6 +734,13 @@ export function assertBundleBudget( if (!manifest.lazyPdfFiles?.length) { throw new Error('bundle has no lazy PDF graph'); } + // #661 C7/AC8: the moon's math, art and template load only at night. + if (!manifest.lazyMoonFiles?.length) { + throw new Error('bundle has no lazy moon graph'); + } + if (manifest.initialViewFiles.some((path) => manifest.lazyMoonFiles.includes(path))) { + throw new Error('initial View graph overlaps lazy moon graph'); + } // #474: designer furniture artwork is lazy; a static import anywhere in the // View graph would pull ~10 KB gzip back into the initial graph silently. if (!manifest.lazyFurnitureArtFiles?.length) { @@ -806,6 +813,7 @@ export function assertBundleBudget( lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes, lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes, lazyPdfGzipBytes: manifest.lazyPdfGzipBytes, + lazyMoonGzipBytes: manifest.lazyMoonGzipBytes, }; } @@ -840,6 +848,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { `lazy locale: ${result.lazyLocaleGzipBytes} B gzip`, `lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`, `lazy PDF: ${result.lazyPdfGzipBytes} B gzip`, + `lazy moon: ${result.lazyMoonGzipBytes} B gzip`, ]; for (const line of lines) console.log(line); const warning = lowHeadroomWarning(headroom); diff --git a/scripts/bundle-manifest.mjs b/scripts/bundle-manifest.mjs index 588fde95..dde69ec1 100644 --- a/scripts/bundle-manifest.mjs +++ b/scripts/bundle-manifest.mjs @@ -11,6 +11,7 @@ const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__'; const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__'; const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__'; const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__'; +const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__'; /** * #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per @@ -110,6 +111,9 @@ export function buildBundleManifest(bundle, fingerprint) { ? 'isometric' : modules.some((id) => id.endsWith('/src/pdf/pdf-export.ts')) ? 'pdf' + // #661: the moon's math, art and template — loaded only at night. + : modules.some((id) => id.endsWith('/src/moon-runtime.ts')) + ? 'moon' // #474: designer furniture artwork — its own lazy chunk, shared by // the editor (static import) and the View runtime (dynamic). : modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts')) @@ -153,6 +157,8 @@ export function buildBundleManifest(bundle, fingerprint) { const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art'); const pdfRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'pdf' || path.includes('pdf-export-')); + const moonRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'moon' + || path.includes('moon-runtime-')); // #627: namespace dictionaries are dynamic imports of LAZY chunks, never of // the initial graph, so they are found by their module, not as a root. const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale') @@ -170,6 +176,7 @@ export function buildBundleManifest(bundle, fingerprint) { const lazyIsometric = graphFrom(isometricRoots); const lazyFurnitureArt = graphFrom(furnitureArtRoots); const lazyPdf = graphFrom(pdfRoots); + const lazyMoon = graphFrom(moonRoots); const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots); const sum = (paths) => [...paths] .reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0); @@ -198,6 +205,8 @@ export function buildBundleManifest(bundle, fingerprint) { lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt), lazyPdfFiles: [...lazyPdf].sort(), lazyPdfGzipBytes: sum(lazyPdf), + lazyMoonFiles: [...lazyMoon].sort(), + lazyMoonGzipBytes: sum(lazyMoon), lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(), lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale), files: files.map(({ _role, ...file }) => file), @@ -257,6 +266,8 @@ export function editorRuntimeRetryUrlPlugin() { .some((id) => id.replaceAll('\\', '/').endsWith('/src/furniture-plan-art.generated.ts'))); const pdf = chunks.find((chunk) => Object.keys(chunk.modules) .some((id) => id.replaceAll('\\', '/').endsWith('/src/pdf/pdf-export.ts'))); + const moon = chunks.find((chunk) => Object.keys(chunk.modules) + .some((id) => id.replaceAll('\\', '/').endsWith('/src/moon-runtime.ts'))); if (!editor) throw new Error('editor runtime chunk was not emitted'); if (!onboarding) throw new Error('onboarding runtime chunk was not emitted'); if (!isometric) throw new Error('isometric runtime chunk was not emitted'); @@ -264,6 +275,7 @@ export function editorRuntimeRetryUrlPlugin() { if (!french) throw new Error('French locale chunk was not emitted'); if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted'); if (!pdf) throw new Error('PDF export runtime chunk was not emitted'); + if (!moon) throw new Error('moon runtime chunk was not emitted'); const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => { const chunk = chunks.find((candidate) => Object.keys(candidate.modules) .some((id) => id.replaceAll('\\', '/').endsWith(entry.module))); @@ -279,6 +291,7 @@ export function editorRuntimeRetryUrlPlugin() { let germanReplacements = 0; let frenchReplacements = 0; let pdfReplacements = 0; + let moonReplacements = 0; for (const chunk of chunks) { if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) { let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName); @@ -322,6 +335,12 @@ export function editorRuntimeRetryUrlPlugin() { pdfReplacements += chunk.code.split(PDF_RETRY_ASSET_TOKEN).length - 1; chunk.code = chunk.code.replaceAll(PDF_RETRY_ASSET_TOKEN, asset); } + if (chunk.code.includes(MOON_RETRY_ASSET_TOKEN)) { + let asset = posix.relative(posix.dirname(chunk.fileName), moon.fileName); + if (!asset.startsWith('.')) asset = `./${asset}`; + moonReplacements += chunk.code.split(MOON_RETRY_ASSET_TOKEN).length - 1; + chunk.code = chunk.code.replaceAll(MOON_RETRY_ASSET_TOKEN, asset); + } for (const entry of namespaceLocales) { if (!chunk.code.includes(entry.token)) continue; let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName); @@ -332,9 +351,9 @@ export function editorRuntimeRetryUrlPlugin() { } if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1 || germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1 - || pdfReplacements !== 1) { + || pdfReplacements !== 1 || moonReplacements !== 1) { throw new Error('lazy retry URL placeholder counts are ' - + `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}, expected 1/1/1/1/1/1/1`); + + `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}, expected 1/1/1/1/1/1/1/1`); } // #627: the same strict rule for every namespace × language token — // exactly one second-attempt URL each, never zero and never two. diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs index 30688e84..ca9be844 100755 --- a/scripts/check-inputs.mjs +++ b/scripts/check-inputs.mjs @@ -342,7 +342,7 @@ export const CHECKS = { entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/no-new-private-writes.mjs', 'scripts/render-layout-read.mjs', 'scripts/bundle-budget.mjs', 'scripts/fix-test-build.mjs', 'scripts/unused-locals-gate.mjs'], // demo/helpers/** — область no-new-private-writes (#629): гейт читает их текст. - roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW], + roots: [...BUILD_INPUTS, 'assets/furniture/**', 'assets/moon/**', 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW], }, changed_mutants: { entries: ['scripts/mutation-*.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'], diff --git a/scripts/config-schema.json b/scripts/config-schema.json index bad07fe2..75d3eb60 100644 --- a/scripts/config-schema.json +++ b/scripts/config-schema.json @@ -413,6 +413,10 @@ "required": true, "type": "str" }, + "config.settings.moon": { + "required": false, + "type": "bool" + }, "config.settings.new_device_ids": { "required": false }, diff --git a/scripts/generate-moon-assets.mjs b/scripts/generate-moon-assets.mjs new file mode 100644 index 00000000..0380045e --- /dev/null +++ b/scripts/generate-moon-assets.mjs @@ -0,0 +1,99 @@ +// Designer moon pack → `src/moon-art.generated.ts` (#661, variant A). +// +// The pack is one full-moon SVG; the card applies the phase as a mask (C5). +// The generator is the gate for what the designer's file may contain: a square +// 512 viewBox with the documented disc, vector shapes and gradients only — no +// filters, raster, fonts, scripts or external references — and at most 30 KB. +// Ids are prefixed so the art can live in the card's shadow root next to +// everything else. `--check` fails when the committed module has drifted. +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const MOON_PACK_DIR = path.join(ROOT, 'assets', 'moon', 'houseplan-1.0.0'); +const OUT = path.join(ROOT, 'src', 'moon-art.generated.ts'); +/** Brief to the designer: ≤ 30 KB before compression. */ +export const MOON_SVG_MAX_BYTES = 30 * 1024; +const ALLOWED_TAGS = new Set([ + 'svg', 'title', 'defs', 'g', 'path', 'circle', 'ellipse', 'mask', 'radialGradient', 'linearGradient', 'stop', +]); +const ID_PREFIX = 'hp-moon-'; + +const fail = (message) => { throw new Error(`Moon pack: ${message}`); }; + +/** pack.json: identity, licence and the disc geometry the mask is built for. */ +export function validateMoonPack(pack) { + if (!pack || typeof pack !== 'object' || Array.isArray(pack)) fail('pack.json must be an object'); + if (pack.schema_version !== 1) fail('schema_version must be 1'); + if (pack.pack_id !== 'houseplan') fail('pack_id must be "houseplan"'); + if (typeof pack.pack_version !== 'string' || !/^\d+\.\d+\.\d+$/.test(pack.pack_version)) fail('pack_version must be semver'); + if (pack.license !== 'MIT') fail('license must be MIT'); + if (typeof pack.source_url !== 'string' || !pack.source_url.startsWith('https://')) fail('source_url must be https'); + if (pack.file !== 'moon.svg') fail('file must be moon.svg'); + if (JSON.stringify(pack.view_box) !== '[0,0,512,512]') fail('view_box must be [0, 0, 512, 512]'); + if (JSON.stringify(pack.disc) !== '{"cx":256,"cy":256,"r":240}') fail('disc must be centred at 256 with r 240'); + if (pack.mirror !== false) fail('mirror must be false (owner 2026-09-29)'); + if (pack.lit_side !== 'left') fail('lit_side must be "left" (owner 2026-09-29)'); + return pack; +} + +/** The SVG body the card inlines: validated, title dropped, ids prefixed. */ +export function moonArtFromSvg(source) { + const text = String(source).replace(/^/, '').trim(); + if (Buffer.byteLength(text, 'utf8') > MOON_SVG_MAX_BYTES) fail(`moon.svg is larger than ${MOON_SVG_MAX_BYTES} bytes`); + if (/`); + } + const root = text.match(/^]*)>([\s\S]*)<\/svg>$/); + if (!root) fail('moon.svg is not one SVG root'); + if (!/\bviewBox="0 0 512 512"/.test(root[1])) fail('moon.svg must have viewBox="0 0 512 512"'); + let body = root[2].replace(/[\s\S]*?<\/title>/g, ''); + const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]); + if (new Set(ids).size !== ids.length) fail('moon.svg repeats an id'); + for (const [, ref] of body.matchAll(/url\(#([^)]+)\)/g)) { + if (!ids.includes(ref)) fail(`moon.svg references a missing id: ${ref}`); + } + if (/[`\\]|\$\{/.test(body)) fail('moon.svg contains template-literal syntax'); + body = body + .replace(/\bid="([^"]+)"/g, (_, id) => `id="${ID_PREFIX}${id}"`) + .replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${ID_PREFIX}${id})`); + return body; +} + +export function generateMoonModule(packDir = MOON_PACK_DIR) { + const pack = validateMoonPack(JSON.parse(fs.readFileSync(path.join(packDir, 'pack.json'), 'utf8'))); + const svg = fs.readFileSync(path.join(packDir, pack.file), 'utf8'); + const art = moonArtFromSvg(svg); + const sha = createHash('sha256').update(svg).digest('hex'); + return [ + '/* Generated by scripts/generate-moon-assets.mjs. DO NOT EDIT. */', + `// Designer pack assets/moon/${pack.pack_id}-${pack.pack_version} (#661). Lazy: imported only by`, + '// moon-runtime.ts, never by the initial View graph (C7).', + '// A static Lit template: no directive, no binding, nothing parsed at runtime but the art.', + "import { svg } from 'lit';", + `export const MOON_PACK = ${JSON.stringify(`${pack.pack_id}-${pack.pack_version}`)};`, + `export const MOON_ART_SHA256 = ${JSON.stringify(sha)};`, + `export const MOON_ART = svg\`${art}\`;`, + '', + ].join('\n'); +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) { + const next = generateMoonModule(); + if (process.argv.includes('--check')) { + const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''; + if (current !== next) { + console.error('src/moon-art.generated.ts is stale: run node scripts/generate-moon-assets.mjs'); + process.exit(1); + } + console.log('moon art is up to date'); + } else { + fs.writeFileSync(OUT, next); + console.log(`wrote ${path.relative(ROOT, OUT)}`); + } +} diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index 088e8d91..928390f0 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 4880, "portPrivates": 94, "harnessPrivates": 100, - "bundleBytes": 2583806 + "bundleBytes": 2616267 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 9a608e37..7bd63c1e 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -5432,8 +5432,8 @@ const MUTANT_DEFINITIONS = [ + 'правка пакета не выбирает frontend и не запускает сверку generated-модулей (#671)', patches: [{ file: 'scripts/check-inputs.mjs', - find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**',", - replace: " roots: [...BUILD_INPUTS, 'test/**', // mutant: furniture package is not a frontend input", + find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'assets/moon/**', 'test/**',", + replace: " roots: [...BUILD_INPUTS, 'assets/moon/**', 'test/**', // mutant: furniture package is not a frontend input", }], }, { @@ -6214,6 +6214,110 @@ const MUTANT_DEFINITIONS = [ replace: ' kind: \'device\', core, presentation,', }], }, + { + id: 'moon-threshold-off', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 AC2: the moon keeps the window rays\' 3° horizon; at 2.9° it is not shown', + patches: [{ + file: 'src/moon.ts', + find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;", + replace: " return phase !== 'day' && altitude >= 0 && fraction >= MOON_MIN_ILLUMINATION;", + }], + }, + { + id: 'moon-new-moon-shown', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 AC2: below 3 % illumination (around new moon) there is no moon', + patches: [{ + file: 'src/moon.ts', + find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;", + replace: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN;", + }], + }, + { + id: 'moon-day-visible', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 AC2: the day phase shows no moon, however high it stands', + patches: [{ + file: 'src/moon.ts', + find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;", + replace: " return altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;", + }], + }, + { + id: 'moon-parallax-off', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 AC1: without the topocentric parallax the altitude by the horizon is off by ' + + 'up to 2° — the 2026-10-31 Moscow point leaves the 1.5° tolerance', + patches: [{ + file: 'src/moon.ts', + find: ' const altitude = geocentric - parallax * Math.cos(geocentric);', + replace: ' const altitude = geocentric;', + }], + }, + { + id: 'moon-lit-side-flips', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 AC3 (owner 2026-09-29): the lit side is always the left one, in both hemispheres', + patches: [{ + file: 'src/moon.ts', + find: ' return `M${c} 0A${c} ${c} 0 0 0 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;', + replace: ' return `M${c} 0A${c} ${c} 0 0 1 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;', + }], + }, + { + id: 'moon-tick-renders-every-time', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 AC4: a 30 s tick over an unchanged moon must not cost a render', + patches: [{ + file: 'src/moon-runtime.ts', + find: ' if (key === watch.key) return;\n', + replace: '', + }], + }, + { + id: 'moon-lazy-gate-off', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 C7: the moon chunk is not loaded by day — the initial View graph pays only the gate', + patches: [{ + file: 'src/moon-gate.ts', + find: " if (!runtime && state.phase !== 'day' && Date.now() >= nextAttempt) {", + replace: ' if (!runtime && Date.now() >= nextAttempt) {', + }], + }, + { + id: 'moon-over-paper', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#661" test/moon.test.mjs', + because: '#661 C6 / owner decision 7: the moon covers only the background — it is the last child ' + + 'of the environment and never stacks by itself; taken out and raised, it would paint over the plan', + patches: [{ + file: 'src/day-cycle-render.ts', + find: ' })}${moon}\n </div>`;', + replace: ' })}\n </div>${moon}`;', + }, { + file: 'src/moon-runtime.ts', + find: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;'", + replace: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;z-index:2;'", + }], + }, + { + id: 'moon-validation-accepts-anything', + guard: 'python3 -m pytest tests_backend/test_settings_moon.py -q -p no:cacheprovider', + because: '#661 AC9: the backend accepts only a boolean moon', + patches: [{ + file: 'custom_components/houseplan/validation.py', + find: ' vol.Optional("moon"): bool,\n', + replace: ' vol.Optional("moon"): object,\n', + }], + }, { id: 'wall-isolated-extra-discarded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 17b2b1e4..37084344 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -27,6 +27,17 @@ /** @type {SmokeLink[]} */ export const SMOKE_LINKS = [ + { + symbols: [ + 'moonLayer', 'renderMoon', 'moonTick', 'moonView', 'moonShownAt', 'moonPhasePath', + 'moonPosition', 'moonIllumination', 'moonFingerprint', 'renderDayCycleEnvironment', + ], + smokes: ['smoke_moon.mjs', 'smoke_daycycle_layer_budget.mjs'], + because: '#661: the moon is observed only as `.hp-moon` in the production bundle — the lazy ' + + 'chunk arriving at night, the 30 s ticker and state updates carrying it through 3° with the ' + + '2 s fade, the plan painting over it pixel for pixel, and no extra composited layer (CDP); ' + + 'no smoke names the functions', + }, { symbols: ['memoIsoLightFloorRooms', 'IsoFirstFrameState', 'isoPaperContext'], smokes: ['smoke_iso_first_frame.mjs'], diff --git a/src/day-cycle-render.ts b/src/day-cycle-render.ts index 2a4073c5..b1d0a8d9 100644 --- a/src/day-cycle-render.ts +++ b/src/day-cycle-render.ts @@ -17,10 +17,15 @@ export function dayCycleStageVars(state: DayCycleState | null): string { ].join(';'); } -/** Four constant layers make gradient changes genuinely cross-fade in CSS. */ +/** + * Four constant layers make gradient changes genuinely cross-fade in CSS. The + * moon (#661) comes last: above the gradients and the sun glow, still inside + * the environment and therefore below the plan paper and everything else. + */ export function renderDayCycleEnvironment( state: DayCycleState | null, viewWeight = 1, + moon: TemplateResult | typeof nothing = nothing, ): TemplateResult | typeof nothing { if (!state) return nothing; const position = [ @@ -39,6 +44,6 @@ export function renderDayCycleEnvironment( data-day-cycle-layer=${phase} style=${background}> <div class="hp-day-cycle-sun" style="background:radial-gradient(circle, ${palette.sun} 0%, transparent 67%)"></div> </div>`; - })} + })}${moon} </div>`; } diff --git a/src/editor-runtime-loader.ts b/src/editor-runtime-loader.ts index 1cb44ac7..3fd29a20 100644 --- a/src/editor-runtime-loader.ts +++ b/src/editor-runtime-loader.ts @@ -1,5 +1,8 @@ export type EditorRuntimeLoaderState = 'idle' | 'loading' | 'ready' | 'failed'; +/** This build's id, embedded at build time; every lazy runtime must carry the same. */ +export const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__'; + export interface EditorRuntimeModule<Runtime> { readonly fingerprint: string; create(): Runtime; diff --git a/src/editors/general-form-state.ts b/src/editors/general-form-state.ts index 1aa30dd2..626aaaeb 100644 --- a/src/editors/general-form-state.ts +++ b/src/editors/general-form-state.ts @@ -43,3 +43,14 @@ export function generalProblems(d: GeneralSettingsDraft): GeneralProblem[] { } return problems; } + +/** #661: the moon switch reads only an explicit `true` (absent, `false` or garbage — off). */ +export function moonDraftOf(settings: { moon?: unknown } | null | undefined): boolean { + return settings?.moon === true; +} + +/** #661: saving writes `true` or removes the key — `false` is never stored (as volumetric_view). */ +export function writeMoonSetting(settings: { moon?: unknown }, on: boolean): void { + if (on) settings.moon = true; + else delete settings.moon; +} diff --git a/src/editors/general-settings-dialog.ts b/src/editors/general-settings-dialog.ts index 3ef90377..09fc0218 100644 --- a/src/editors/general-settings-dialog.ts +++ b/src/editors/general-settings-dialog.ts @@ -81,7 +81,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ : nothing; return html`<hp-dialog .hass=${host.hass} data-kind="settings" form-shell wide - .title=${t('gs.title')} icon="mdi:cog-outline" @hp-close=${requestClose}> + .title=${st('gs.title')} icon="mdi:cog-outline" @hp-close=${requestClose}> <div class="body hpf-form"> ${formCard({ id: 'display', @@ -94,7 +94,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ })} ${toggleRow({ id: 'gs-radar-live', icon: 'mdi:radar', - title: t('gs.radar_show_live'), caption: st('gs.radar_show_live_hint'), + title: st('gs.radar_show_live'), caption: st('gs.radar_show_live_hint'), checked: d.radarShowLive, onChange: (v) => set({ radarShowLive: v }), })} ${toggleRow({ @@ -116,11 +116,11 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ title: t('gs.card_fills'), help: this._help('gs.card_fills.help'), body: html` - ${subsection({ title: t('gs.light_group') })} + ${subsection({ title: st('gs.light_group') })} ${colorTiles([tile('light_on', 'gs.light_on'), tile('light_off', 'gs.light_off'), tile('light_none', 'gs.light_none')])} - ${subsection({ title: t('gs.temp_group') })} + ${subsection({ title: st('gs.temp_group') })} ${colorTiles([tile('temp_cold', 'gs.temp_cold'), tile('temp_ok', 'gs.temp_ok'), tile('temp_hot', 'gs.temp_hot')])} - ${subsection({ title: t('gs.lqi_group') })} + ${subsection({ title: st('gs.lqi_group') })} ${colorTiles([tile('lqi_low', 'gs.lqi_low'), tile('lqi_high', 'gs.lqi_high')])}`, })} ${formCard({ @@ -129,7 +129,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ body: html` ${colorTiles([tile('glow_base', 'gs.glow_base'), tile('glow_light', 'gs.glow_light')])} ${field({ - label: t('gs.glow_radius'), htmlFor: 'gs-glow-radius', help: this._help('gs.glow_radius.help'), + label: st('gs.glow_radius'), htmlFor: 'gs-glow-radius', help: this._help('gs.glow_radius.help'), error: glowProblem ? st(glowProblem.message) : undefined, control: unitInput({ id: 'gs-glow-radius', value: d.glowRadiusInput, min: 0.5, step: 0.5, invalid: !!glowProblem, @@ -146,13 +146,13 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ title: t('gs.card_plan'), body: html` ${colorRow({ - label: t('gs.wall_fill'), + label: st('gs.wall_fill'), picker: colorField({ hex: d.colors.wall_fill.c, opacity: d.colors.wall_fill.a, opacityLabel: t('space.opacity'), onOpacity: (a) => this._setFillColor('wall_fill', { c: d.colors.wall_fill.c, a }), resetLabel: t('btn.reset'), onReset: () => this._setFillColor('wall_fill', { ...DEFAULT_FILL_COLORS.wall_fill }), - picker: html`<hp-color-opacity .label=${t('gs.wall_fill')} hide-label flat-swatch + picker: html`<hp-color-opacity .label=${st('gs.wall_fill')} hide-label flat-swatch .opacityLabel=${t('space.opacity')} .pickerLabels=${host._colorPickerLabels} .color=${d.colors.wall_fill.c} .opacity=${d.colors.wall_fill.a} .showOpacity=${true} @hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => { @@ -161,11 +161,11 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ }), })} ${field({ - label: t('gs.bg_mode'), help: this._help('gs.bg_mode.help'), + label: st('gs.bg_mode'), help: this._help('gs.bg_mode.help'), control: segmented({ name: 'gs-bg-mode', value: d.bgMode === 'daynight' ? 'daynight' : 'static', - ariaLabel: t('gs.bg_mode'), + ariaLabel: st('gs.bg_mode'), options: [ { value: 'static', label: t('gs.bg_static') }, { value: 'daynight', label: t('gs.bg_daynight') }, @@ -201,7 +201,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ <div class="hpf-inline hpf-wrap"> ${unitInput({ id: 'gs-north', value: d.northDegInput, unit: '°', - min: 0, max: 359, step: 1, placeholder: t('gs.north_ph'), invalid: !!northProblem, + min: 0, max: 359, step: 1, placeholder: st('gs.north_ph'), invalid: !!northProblem, onInput: (raw) => { const trimmed = raw.trim(); const n = trimmed === '' ? null : strictNumber(trimmed); @@ -221,10 +221,15 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ ${northProblem ? html`<p class="hpf-error" role="alert">${st(northProblem.message)}</p>` : nothing} ${toggleRow({ id: 'gs-sun-rays', icon: 'mdi:weather-sunny', - title: t('gs.sun_rays'), caption: st('gs.sun_rays_hint'), + title: st('gs.sun_rays'), caption: st('gs.sun_rays_hint'), checked: d.sunRays, onChange: (v) => set({ sunRays: v }), })} - ${renderSunRayOriginSegment(d.sunRayOrigin, (key) => t(key), (sunRayOrigin) => set({ sunRayOrigin }))}`, + ${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'), + checked: d.moon, onChange: (v) => set({ moon: v }), + })}`, })} ${formCard({ id: 'data', @@ -234,28 +239,28 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ ${subsection({ title: t('gs.backup_group'), help: this._help('gs.backup_group.help') })} <div class="hpf-actions"> <button class="btn" @click=${() => this._openBackupExport()}> - <ha-icon icon="mdi:download"></ha-icon>${t('backup.export_open')} + <ha-icon icon="mdi:download"></ha-icon>${st('backup.export_open')} </button> <span class="backupupload"> <button class="btn" type="button" @click=${(e: Event) => ((e.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}> - <ha-icon icon="mdi:upload"></ha-icon>${t('backup.import_open')} + <ha-icon icon="mdi:upload"></ha-icon>${st('backup.import_open')} </button> <input type="file" accept="application/json,.json" @change=${(event: Event) => this._pickBackupImport(event)} /> </span> ${host._canOptimizeUndo && host._undoKind === 'import' ? html` <button class="btn" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}> - <ha-icon icon="mdi:undo-variant"></ha-icon>${t('backup.undo_import')} + <ha-icon icon="mdi:undo-variant"></ha-icon>${st('backup.undo_import')} </button>` : nothing} </div>` : nothing} - ${subsection({ title: t('gs.grid_group'), help: this._help('gs.grid_group.help') })} + ${subsection({ title: st('gs.grid_group'), help: this._help('gs.grid_group.help') })} <div class="hpf-actions"> <button class="btn" @click=${() => this.optimizePlans.open()}> <ha-icon icon="mdi:broom"></ha-icon>${t('gs.align_all')} </button> ${host._canOptimizeUndo && host._undoKind !== 'import' ? html` <button class="btn" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}> - <ha-icon icon="mdi:undo-variant"></ha-icon>${t('gs.optimize_undo')} + <ha-icon icon="mdi:undo-variant"></ha-icon>${st('gs.optimize_undo')} </button>` : nothing} </div>`, })} @@ -266,7 +271,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: host._imperial ? 9.8 : 3, glowRadiusInput: String(host._imperial ? 9.8 : 3), bgColor: null, northDeg: null, northDegInput: '', bgMode: 'daynight', sunRays: false, sunRayOrigin: 'inner', - showRoomTooltip: true, radarShowLive: true, volumetricView: false, zigbeeTopology: { enabled: false, z2mBaseTopics: [] }, + showRoomTooltip: true, radarShowLive: true, volumetricView: false, moon: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] }, })}>${t('gs.reset')}</button> </div> ${footerStatus(problems.length diff --git a/src/furniture-art-runtime.ts b/src/furniture-art-runtime.ts index ed3679fa..9402bad6 100755 --- a/src/furniture-art-runtime.ts +++ b/src/furniture-art-runtime.ts @@ -28,6 +28,7 @@ * get one `requestUpdate()` when the runtime settles. */ import type { FurnitureGraphic } from './furniture'; +import { ENTRY_BUILD_FINGERPRINT } from './editor-runtime-loader'; export type FurnitureArtState = 'ready' | 'pending' | 'fallback'; @@ -211,7 +212,6 @@ export function ensureFurnitureArtFor( /* Page-scoped runtime wired to the real chunk. The retry token is replaced * at build time with the exact content-hashed asset (bundle-manifest.mjs). */ -const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__'; const FURNITURE_ART_RETRY_ASSET = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__'; let retrySeq = 0; diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 9c51888c..502d3387 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -48,6 +48,7 @@ import { rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin, } from './sun'; import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render'; +import { moonLayer } from './moon-gate'; import { renderPaperShapes, type PaperShape } from './render/paper-scene'; import { furnitureGraphic, furnitureArtIsLazy, @@ -231,7 +232,7 @@ import { roomFitGeometryBounds, roomFitOwnerFromPath, STAGE_TAP_DISTANCE_PX, type RoomFitGestureCandidate, } from './room-fit'; -import { EditorRuntimeLoader, lazyLoadFailureMessage, safeRuntimeDiagnostic, type EditorRuntimeLoaderState } from './editor-runtime-loader'; +import { ENTRY_BUILD_FINGERPRINT, EditorRuntimeLoader, lazyLoadFailureMessage, safeRuntimeDiagnostic, type EditorRuntimeLoaderState } from './editor-runtime-loader'; import { FURNITURE_ART_RUNTIME, composeUnsub, ensureFurnitureArtFor, furnitureArtBootPending, subscribeFurnitureArtLoadFailures } from './furniture-art-runtime'; import type { BackdropGuardState } from './backdrop-pick'; import { @@ -302,7 +303,6 @@ import { isoWallMaterialVars, parseCssColor } from './iso-materials'; import { I import { renderIsoTileShadow } from './iso-tiles'; import { displayVersion } from './card-version'; const CARD_VERSION = '1.78.0'; -const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__'; const EDITOR_RETRY_ASSET = '__HOUSEPLAN_EDITOR_RETRY_ASSET__'; const ISO_RETRY_ASSET = '__HOUSEPLAN_ISO_RETRY_ASSET__'; const PDF_RETRY_ASSET = '__HOUSEPLAN_PDF_RETRY_ASSET__'; @@ -2050,7 +2050,7 @@ export class HouseplanCard extends LitElement { colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null; /** sun on the plan (docs/SUN.md) */ northDeg: number | null; northDegInput: string; bgMode: 'static' | 'daynight'; sunRays: boolean; sunRayOrigin: SunRayOrigin; - showRoomTooltip: boolean; zigbeeTopology: import('./zigbee-topology-settings').ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; busy: boolean; + showRoomTooltip: boolean; zigbeeTopology: import('./zigbee-topology-settings').ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; moon: boolean; busy: boolean; } | null = null; private _pdfDialog = false; private _supportDialog: SupportDialogState | null = null; @@ -9772,6 +9772,7 @@ export class HouseplanCard extends LitElement { bg_mode: gd.bgMode, sun_rays: gd.sunRays, sun_ray_origin: gd.sunRayOrigin, + moon: gd.moon, }; } @@ -10873,7 +10874,7 @@ export class HouseplanCard extends LitElement { @pointerleave=${(e: PointerEvent) => this._stagePointerLeave(e)} @pointerup=${(e: PointerEvent) => this._stagePointerUp(e)} @pointercancel=${(e: PointerEvent) => this._stagePointerCancel(e)}> - ${renderDayCycleEnvironment(dayCycle, dayCycleWeight)} + ${renderDayCycleEnvironment(dayCycle, dayCycleWeight, moonLayer(this, this._sunGlobal(), dayCycle))} ${this._editorRuntime ? this._renderEditorSecondary() : nothing} <div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}" ?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy} diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 0d2f4c46..138d2937 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -110,7 +110,7 @@ import { roomTempThresholdDraft, roomTempThresholdInputValues, strictNumber, type SpaceDialogState, } from './space-dialog'; import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state'; -import { generalProblems, rememberGeneralBaseline } from './editors/general-form-state'; +import { generalProblems, moonDraftOf, rememberGeneralBaseline, writeMoonSetting } from './editors/general-form-state'; import { rememberRoomBaseline } from './editors/room-form-state'; import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state'; import { commitPlanOptimization } from './plan-optimize-write'; @@ -713,9 +713,9 @@ export interface HouseplanEditorHostPort { _sentPos: Map<string, DeviceLayout[string] | null>; _serverCfg: ServerConfig | null; _serverStorage: boolean; - _settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; sun_ray_origin?: SunRayOrigin; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; }; + _settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; moon?: boolean; sun_ray_origin?: SunRayOrigin; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; }; _terminalFrame: 0 | 1 | 2; - _settingsDialog: { colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null; northDeg: number | null; northDegInput: string; bgMode: "static" | "daynight"; sunRays: boolean; sunRayOrigin: SunRayOrigin; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; busy: boolean; } | null; + _settingsDialog: { colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null; northDeg: number | null; northDegInput: string; bgMode: "static" | "daynight"; sunRays: boolean; sunRayOrigin: SunRayOrigin; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; moon: boolean; busy: boolean; } | null; _supportDialog: SupportDialogState | null; _showAll: boolean; _showHidden: boolean; @@ -8454,7 +8454,7 @@ public _openSettingsDialog = (): void => { bgMode: bgModeOf(this.host._settings, {}), sunRays: sunRaysOn(this.host._settings, {}), sunRayOrigin: sunRayOriginOf(this.host._settings), - radarShowLive: this.host._settings.radar?.show_live !== false, volumetricView: volumetricViewOf(this.host._settings), + radarShowLive: this.host._settings.radar?.show_live !== false, volumetricView: volumetricViewOf(this.host._settings), moon: moonDraftOf(this.host._settings), showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false, }; // #600 К10: снимок на момент открытия — от него считается «есть изменения». @@ -9512,6 +9512,7 @@ public _updateDecorStyle(next: DecorStyle): void { else settings.show_room_tooltip = false; if (d.volumetricView) settings.volumetric_view = true; else delete settings.volumetric_view; + writeMoonSetting(settings, d.moon); const radarSettings = settings.radar; if (!radarSettings || radarSettings.version == null || radarSettings.version === 1) { settings.radar = { ...(radarSettings || {}), version: 1 }; diff --git a/src/i18n/de.json b/src/i18n/de.json index 2ace4bad..e6dac683 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -481,20 +481,15 @@ "pdf.asset_failed": "Ein Hintergrund- oder Dekorbild konnte nicht geladen werden.", "pdf.scale": "Maßstab 1:{n}", "pdf.north": "Nord", - "gs.title": "Allgemeine Einstellungen", - "gs.light_group": "Beleuchtung", "gs.light_on": "Lichter an", "gs.light_off": "Alle Lichter aus", - "gs.temp_group": "Temperatur", "gs.temp_cold": "Kalt", "gs.temp_ok": "Angenehm", "gs.temp_hot": "Heiß", - "gs.lqi_group": "Zigbee-Signal", "gs.lqi_low": "Schwaches Signal", "gs.lqi_high": "Starkes Signal", "gs.reset": "Auf Standard zurücksetzen", "gs.saved": "Allgemeine Einstellungen gespeichert", - "gs.radar_show_live": "Live-Präsenz auf dem Plan anzeigen", "gs.volumetric_view": "Plan in 2.5D anzeigen", "radar.title": "Präsenz auf dem Plan", "radar.help": "Verbinde diese Markierung mit exakten Home-Assistant-Radarentitäten und wähle Position sowie Koordinatenprofil. Live-Positionen werden auf den ausgewählten Raum begrenzt.", @@ -730,8 +725,6 @@ "gs.glow_group": "Glow von Lichtquellen", "gs.glow_base": "Grunddunkelheit des Hauses", "gs.glow_light": "Standardlichtfarbe / Intensität", - "gs.wall_fill": "Wandfüllung", - "gs.glow_radius": "Glow-Radius", "gs.glow_radius.help": "Legt den allgemeinen Radius des Lichtscheins in Metern oder Fuß fest; ein gerätespezifischer Radius überschreibt diesen Wert.", "gs.glow_radius.help.aria": "Hilfe: allgemeiner Glow-Radius", "gs.unit_m": "m", @@ -1014,20 +1007,17 @@ "vac.trail_always": "Immer", "gs.bg_color": "Hintergrund um den Plan", "gs.bg_default": "Standardthema", - "gs.bg_mode": "Planhintergrund", "gs.bg_mode.help": "„Folgt der Sonne“ verwendet sun.sun und ersatzweise die lokale Browserzeit; „Statische Farbe“ zeigt immer die ausgewählte Farbe.", "gs.bg_mode.help.aria": "Hilfe: allgemeiner Planhintergrund", "gs.bg_static": "Statische Farbe", "gs.bg_daynight": "Folgt der Sonne", - "gs.sun_group": "Sonne", + "gs.sun_group": "Sonne und Mond", "gs.sun_missing": "Die sun.sun-Entität wurde nicht gefunden — der Hintergrund folgt Ihrer lokalen Uhr; Fensterstrahlen sind nicht verfügbar.", "gs.north": "Norden auf dem Plan", "gs.north.help": "Der Winkel wird im Uhrzeigersinn von der oberen Vertikalen des Plans gemessen; Norden wird für Fensterstrahlen benötigt, nicht für den Hintergrund „Folgt der Sonne“.", "gs.north.help.aria": "Hilfe: allgemeine Nordrichtung", - "gs.north_ph": "nicht gesetzt", "gs.north_clear": "Löschen", "gs.north_letter": "N", - "gs.sun_rays": "Sonnenlicht durch Fenster", "gs.sun_ray_origin": "Sonnenstrahlen", "gs.sun_ray_origin.inner": "Von den inneren Fensterecken", "gs.sun_ray_origin.outer": "Von den äußeren Fensterecken", @@ -1050,7 +1040,6 @@ "canvas.far_objects": "{n} Objekt(e) weit vom Plan entfernt", "canvas.show_far": "Anzeigen", "canvas.home_tip": "Der Plan ist dort drüben — klicken, um ihn anzupassen", - "gs.grid_group": "Planwartung", "gs.align_all": "Pläne optimieren", "gs.align_title": "Pläne optimieren", "gs.align_none": "Alle Pläne verwenden bereits das aktuelle optimierte Datenmodell.", @@ -1109,7 +1098,6 @@ "gs.align_warn": "Absichtlich zwischen Rasterknoten platzierte Elemente werden sich bewegen. Nach der Operation ist nur eine Rückgängig-Funktion verfügbar, und zwar nur bis zur nächsten Planbearbeitung.", "gs.align_run": "Optimieren", "gs.align_done": "Pläne optimiert: {n} Elemente bewegt, {m} Datensätze beibehalten, {r} Verweise repariert", - "gs.optimize_undo": "Letzte Optimierung rückgängig machen", "gs.optimize_undone": "Die letzte Optimierung wurde rückgängig gemacht", "decor.furniture": "Möbel", "decor.image": "Bild", @@ -1273,8 +1261,6 @@ "vac.residual_message": "Die zugeordneten Räume weichen um bis zu {error} voneinander ab. Diese ungefähre Kalibrierung anwenden, manuell verfeinern oder ohne Änderung der gespeicherten Einrichtung abbrechen.", "vac.apply_proposal": "Anwenden", "gs.backup_group": "Sichern und übertragen", - "backup.export_open": "Exportieren", - "backup.import_open": "Importieren", "backup.export_title": "House Plan exportieren", "backup.import_title": "House Plan importieren", "backup.export_hint": "Wählen Sie, ob das Backup die gesamte House Plan-Konfiguration oder nur den aktuellen Bereich enthalten soll.", @@ -1391,7 +1377,6 @@ "backup.add": "Bereich hinzufügen", "backup.space_done": "Bereich importiert: {rooms} Räume, {markers} Geräte, {refs} bestehende Referenzen wiederhergestellt", "backup.full_done": "Backup wiederhergestellt: {spaces} Bereiche, {rooms} Räume, {markers} Geräte", - "backup.undo_import": "Letzten vollständigen Import rückgängig machen", "backup.import_undone": "Vollständiger Import rückgängig gemacht", "backdrop.large_title": "Großes Bild", "backdrop.large_body": "Dieses Bild ist {w}×{h} ({fileMb} MB Datei) und benötigt etwa {decodedMb} MB Speicher zur Anzeige. Auf Tablets kann das die Seite zum Absturz bringen.", diff --git a/src/i18n/en.json b/src/i18n/en.json index da7ae9f8..55060861 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -481,20 +481,15 @@ "pdf.asset_failed": "A backdrop or decor image could not be loaded.", "pdf.scale": "Scale 1:{n}", "pdf.north": "North", - "gs.title": "General settings", - "gs.light_group": "Lights", "gs.light_on": "Lights on", "gs.light_off": "All lights off", - "gs.temp_group": "Temperature", "gs.temp_cold": "Cold", "gs.temp_ok": "Comfortable", "gs.temp_hot": "Hot", - "gs.lqi_group": "Zigbee signal", "gs.lqi_low": "Weak signal", "gs.lqi_high": "Strong signal", "gs.reset": "Reset to defaults", "gs.saved": "General settings saved", - "gs.radar_show_live": "Show live presence on the plan", "gs.volumetric_view": "Show the plan in 2.5D", "radar.title": "Presence on the plan", "radar.help": "Connect this marker to exact Home Assistant radar entities, place the sensor and choose its coordinate profile. Live positions are clipped to the selected room.", @@ -730,8 +725,6 @@ "gs.glow_group": "Light-source glow", "gs.glow_base": "House darkness", "gs.glow_light": "Default light color / intensity", - "gs.wall_fill": "Wall fill", - "gs.glow_radius": "Glow radius", "gs.glow_radius.help": "Sets the default glow radius in metres or feet; a device-specific radius overrides it.", "gs.glow_radius.help.aria": "Help: default glow radius", "gs.unit_m": "m", @@ -1014,20 +1007,17 @@ "vac.trail_always": "Always", "gs.bg_color": "Background around the plan", "gs.bg_default": "Theme default", - "gs.bg_mode": "Plan background", "gs.bg_mode.help": "“Follows the Sun” uses sun.sun, falling back to the browser's local clock; Static always shows the selected colour.", "gs.bg_mode.help.aria": "Help: general plan background", "gs.bg_static": "Static color", "gs.bg_daynight": "Follows the Sun", - "gs.sun_group": "Sun", + "gs.sun_group": "Sun and Moon", "gs.sun_missing": "The sun.sun entity was not found — the background follows your local clock; window rays are unavailable.", "gs.north": "North on the plan", "gs.north.help": "The angle is measured clockwise from the plan's upward vertical; north is required for window rays, not for the “Follows the Sun” background.", "gs.north.help.aria": "Help: general north direction", - "gs.north_ph": "not set", "gs.north_clear": "Clear", "gs.north_letter": "N", - "gs.sun_rays": "Sunlight through windows", "gs.sun_ray_origin": "Sun rays", "gs.sun_ray_origin.inner": "From the inner window corners", "gs.sun_ray_origin.outer": "From the outer window corners", @@ -1050,7 +1040,6 @@ "canvas.far_objects": "{n} object(s) far from the plan", "canvas.show_far": "Show", "canvas.home_tip": "The plan is over there — click to fit it", - "gs.grid_group": "Plan maintenance", "gs.align_all": "Optimize plans", "gs.align_title": "Optimize plans", "gs.align_none": "All plans already use the current optimized data model.", @@ -1109,7 +1098,6 @@ "gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.", "gs.align_run": "Optimize", "gs.align_done": "Plans optimized: {n} elements moved, {m} records maintained, {r} references repaired", - "gs.optimize_undo": "Undo last optimization", "gs.optimize_undone": "The last optimization was undone", "decor.furniture": "Furniture", "decor.image": "Image", @@ -1273,8 +1261,6 @@ "vac.residual_message": "The matched rooms disagree by up to {error}. Apply this approximate calibration, refine it manually, or cancel without changing the saved setup.", "vac.apply_proposal": "Apply", "gs.backup_group": "Backup and transfer", - "backup.export_open": "Export", - "backup.import_open": "Import", "backup.export_title": "Export House Plan", "backup.import_title": "Import House Plan", "backup.export_hint": "Choose whether the backup should contain the whole House Plan configuration or only the current space.", @@ -1391,7 +1377,6 @@ "backup.add": "Add space", "backup.space_done": "Space imported: {rooms} rooms, {markers} devices, {refs} existing references restored", "backup.full_done": "Backup restored: {spaces} spaces, {rooms} rooms, {markers} devices", - "backup.undo_import": "Undo last full import", "backup.import_undone": "Full import undone", "backdrop.large_title": "Large image", "backdrop.large_body": "This image is {w}×{h} ({fileMb} MB file) and needs about {decodedMb} MB of memory to display. On tablets this can crash the page.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index e7f6a18f..6cf61d9d 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -481,20 +481,15 @@ "pdf.asset_failed": "Impossible de charger une image de fond ou de décoration.", "pdf.scale": "Échelle 1:{n}", "pdf.north": "Nord", - "gs.title": "Paramètres généraux", - "gs.light_group": "Éclairage", "gs.light_on": "Lumières allumées", "gs.light_off": "Toutes les lumières éteintes", - "gs.temp_group": "Température", "gs.temp_cold": "Froid", "gs.temp_ok": "Confortable", "gs.temp_hot": "Chaud", - "gs.lqi_group": "Signal Zigbee", "gs.lqi_low": "Signal faible", "gs.lqi_high": "Signal fort", "gs.reset": "Réinitialiser par défaut", "gs.saved": "Paramètres généraux enregistrés", - "gs.radar_show_live": "Afficher la présence en direct sur le plan", "gs.volumetric_view": "Afficher le plan en 2.5D", "radar.title": "Présence sur le plan", "radar.help": "Reliez ce marqueur aux entités radar exactes de Home Assistant, placez le capteur et choisissez son profil de coordonnées. Les positions en direct sont limitées à la pièce sélectionnée.", @@ -730,8 +725,6 @@ "gs.glow_group": "Halo des sources lumineuses", "gs.glow_base": "Obscurité de la maison", "gs.glow_light": "Couleur / intensité de lumière par défaut", - "gs.wall_fill": "Remplissage des murs", - "gs.glow_radius": "Rayon du halo", "gs.glow_radius.help": "Définit le rayon général du halo en mètres ou en pieds ; le rayon propre à un appareil remplace cette valeur.", "gs.glow_radius.help.aria": "Aide : rayon général du halo", "gs.unit_m": "m", @@ -1014,20 +1007,17 @@ "vac.trail_always": "Toujours", "gs.bg_color": "Arrière-plan autour du plan", "gs.bg_default": "Valeur par défaut du thème", - "gs.bg_mode": "Arrière-plan du plan", "gs.bg_mode.help": "« Suit le soleil » utilise sun.sun ou, à défaut, l’heure locale du navigateur ; « Couleur statique » affiche toujours la couleur choisie.", "gs.bg_mode.help.aria": "Aide : arrière-plan général du plan", "gs.bg_static": "Couleur statique", "gs.bg_daynight": "Suit le soleil", - "gs.sun_group": "Soleil", + "gs.sun_group": "Soleil et Lune", "gs.sun_missing": "L’entité sun.sun est introuvable — l’arrière-plan suit votre heure locale ; les rayons de soleil par les fenêtres sont indisponibles.", "gs.north": "Nord sur le plan", "gs.north.help": "L’angle se mesure dans le sens horaire depuis la verticale supérieure du plan ; le nord sert aux rayons des fenêtres, pas à l’arrière-plan « Suit le soleil ».", "gs.north.help.aria": "Aide : direction générale du nord", - "gs.north_ph": "non défini", "gs.north_clear": "Effacer", "gs.north_letter": "N", - "gs.sun_rays": "Lumière du soleil à travers les fenêtres", "gs.sun_ray_origin": "Rayons du soleil", "gs.sun_ray_origin.inner": "Depuis les angles intérieurs des fenêtres", "gs.sun_ray_origin.outer": "Depuis les angles extérieurs des fenêtres", @@ -1050,7 +1040,6 @@ "canvas.far_objects": "{n} objet(s) éloigné(s) du plan", "canvas.show_far": "Afficher", "canvas.home_tip": "Le plan est là-bas — cliquez pour l’ajuster à l’écran", - "gs.grid_group": "Maintenance du plan", "gs.align_all": "Optimiser les plans", "gs.align_title": "Optimiser les plans", "gs.align_none": "Tous les plans utilisent déjà le modèle de données optimisé actuel.", @@ -1109,7 +1098,6 @@ "gs.align_warn": "Les éléments volontairement placés entre les nœuds de la grille seront déplacés. Une seule annulation est disponible après l’opération, uniquement jusqu’à la prochaine modification du plan.", "gs.align_run": "Optimiser", "gs.align_done": "Plans optimisés : {n} élément(s) déplacé(s), {m} enregistrement(s) maintenu(s), {r} référence(s) réparée(s)", - "gs.optimize_undo": "Annuler la dernière optimisation", "gs.optimize_undone": "La dernière optimisation a été annulée", "decor.furniture": "Mobilier", "decor.image": "Image", @@ -1273,8 +1261,6 @@ "vac.residual_message": "Les pièces correspondantes présentent un écart maximal de {error}. Appliquez cette calibration approximative, affinez-la manuellement ou annulez sans modifier la configuration enregistrée.", "vac.apply_proposal": "Appliquer", "gs.backup_group": "Sauvegarde et transfert", - "backup.export_open": "Exporter", - "backup.import_open": "Importer", "backup.export_title": "Exporter House Plan", "backup.import_title": "Importer House Plan", "backup.export_hint": "Choisissez si la sauvegarde doit contenir toute la configuration House Plan ou uniquement l’espace actuel.", @@ -1391,7 +1377,6 @@ "backup.add": "Ajouter un espace", "backup.space_done": "Espace importé : {rooms} pièces, {markers} appareils, {refs} références existantes restaurées", "backup.full_done": "Sauvegarde restaurée : {spaces} espaces, {rooms} pièces, {markers} appareils", - "backup.undo_import": "Annuler la dernière importation complète", "backup.import_undone": "Importation complète annulée", "backdrop.large_title": "Grande image", "backdrop.large_body": "Cette image fait {w}×{h} (fichier de {fileMb} Mo) et nécessite environ {decodedMb} Mo de mémoire pour s'afficher. Sur tablette, cela peut faire planter la page.", diff --git a/src/i18n/registry.ts b/src/i18n/registry.ts index c9deaba4..1452fb3f 100644 --- a/src/i18n/registry.ts +++ b/src/i18n/registry.ts @@ -6,8 +6,8 @@ import { type LazyLanguageModule, type LocaleDictionary, } from './language-runtime'; +import { ENTRY_BUILD_FINGERPRINT as BUILD_FINGERPRINT } from '../editor-runtime-loader'; -const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__'; const GERMAN_RETRY_ASSET = '__HOUSEPLAN_DE_RETRY_ASSET__'; const FRENCH_RETRY_ASSET = '__HOUSEPLAN_FR_RETRY_ASSET__'; diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 1ecc9588..453979ed 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -481,20 +481,15 @@ "pdf.asset_failed": "Не удалось загрузить подложку или изображение декора.", "pdf.scale": "Масштаб 1:{n}", "pdf.north": "Север", - "gs.title": "Общие настройки", - "gs.light_group": "Освещение", "gs.light_on": "Свет включён", "gs.light_off": "Весь свет выключен", - "gs.temp_group": "Температура", "gs.temp_cold": "Холодно", "gs.temp_ok": "Комфорт", "gs.temp_hot": "Жарко", - "gs.lqi_group": "Сигнал Zigbee", "gs.lqi_low": "Слабый сигнал", "gs.lqi_high": "Сильный сигнал", "gs.reset": "Сбросить к умолчаниям", "gs.saved": "Общие настройки сохранены", - "gs.radar_show_live": "Показывать текущее присутствие на плане", "gs.volumetric_view": "Объёмный вид плана (2.5D)", "radar.title": "Присутствие на плане", "radar.help": "Свяжите маркер с точными сущностями радара Home Assistant, укажите положение датчика и профиль координат. Живые позиции обрезаются по выбранной комнате.", @@ -730,8 +725,6 @@ "gs.glow_group": "Свечение источников света", "gs.glow_base": "Темнота дома", "gs.glow_light": "Цвет света по умолчанию / интенсивность", - "gs.wall_fill": "Цвет заливки стен", - "gs.glow_radius": "Радиус свечения", "gs.glow_radius.help": "Задаёт общий радиус светового пятна в метрах или футах; персональный радиус устройства заменяет это значение.", "gs.glow_radius.help.aria": "Подсказка: общий радиус свечения", "gs.unit_m": "м", @@ -1014,20 +1007,17 @@ "vac.trail_always": "Показывать всегда", "gs.bg_color": "Цвет фона вокруг плана", "gs.bg_default": "Как в теме", - "gs.bg_mode": "Фон плана", "gs.bg_mode.help": "«Следует за Солнцем» использует sun.sun, а при недоступности — локальные часы браузера; статичный режим всегда показывает выбранный цвет.", "gs.bg_mode.help.aria": "Подсказка: общий фон плана", "gs.bg_static": "Статичный цвет", "gs.bg_daynight": "Следует за Солнцем", - "gs.sun_group": "Солнце", + "gs.sun_group": "Солнце и Луна", "gs.sun_missing": "Сущность sun.sun не найдена — фон следует локальным часам, а оконные лучи недоступны.", "gs.north": "Север на плане", "gs.north.help": "Угол отсчитывается по часовой стрелке от верхней вертикали плана; север нужен для оконных лучей, а не для фона «Следует за Солнцем».", "gs.north.help.aria": "Подсказка: общее направление севера", - "gs.north_ph": "не задан", "gs.north_clear": "Сбросить", "gs.north_letter": "С", - "gs.sun_rays": "Солнце в окнах", "gs.sun_ray_origin": "Лучи солнца", "gs.sun_ray_origin.inner": "От внутренних углов окон", "gs.sun_ray_origin.outer": "От внешних углов окон", @@ -1050,7 +1040,6 @@ "canvas.far_objects": "Объектов далеко от плана: {n}", "canvas.show_far": "Показать", "canvas.home_tip": "План там — нажмите, чтобы вписать", - "gs.grid_group": "Обслуживание планов", "gs.align_all": "Оптимизировать планы", "gs.align_title": "Оптимизировать планы", "gs.align_none": "Все планы уже используют актуальную и оптимальную модель данных.", @@ -1109,7 +1098,6 @@ "gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.", "gs.align_run": "Оптимизировать", "gs.align_done": "Планы оптимизированы: сдвинуто элементов — {n}, обслужено записей — {m}, исправлено ссылок — {r}", - "gs.optimize_undo": "Отменить последнюю оптимизацию", "gs.optimize_undone": "Последняя оптимизация отменена", "decor.furniture": "Мебель", "decor.image": "Изображение", @@ -1273,8 +1261,6 @@ "vac.residual_message": "Совпавшие комнаты расходятся максимум на {error}. Примените приблизительную калибровку, подгоните её вручную либо отмените без изменения сохранённых настроек.", "vac.apply_proposal": "Применить", "gs.backup_group": "Резервная копия и перенос", - "backup.export_open": "Экспорт", - "backup.import_open": "Импорт", "backup.export_title": "Экспорт House Plan", "backup.import_title": "Импорт House Plan", "backup.export_hint": "Выберите, должна ли копия содержать всю конфигурацию House Plan или только текущее пространство.", @@ -1391,7 +1377,6 @@ "backup.add": "Добавить пространство", "backup.space_done": "Пространство импортировано: комнат — {rooms}, устройств — {markers}, существующих ссылок восстановлено — {refs}", "backup.full_done": "Копия восстановлена: пространств — {spaces}, комнат — {rooms}, устройств — {markers}", - "backup.undo_import": "Отменить последний полный импорт", "backup.import_undone": "Полный импорт отменён", "backdrop.large_title": "Большое изображение", "backdrop.large_body": "Изображение {w}×{h} ({fileMb} МБ), для показа потребуется около {decodedMb} МБ памяти. На планшетах это может привести к падению страницы.", diff --git a/src/i18n/settings/de.json b/src/i18n/settings/de.json index 118e8eb0..1fd7e149 100644 --- a/src/i18n/settings/de.json +++ b/src/i18n/settings/de.json @@ -55,6 +55,8 @@ "gs.bg_custom_hint": "Eigene Hintergrundfarbe.", "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.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}", @@ -91,5 +93,20 @@ "fill.seg_lqi": "Zigbee", "fill.seg_light": "Licht", "fill.seg_temp": "Temperatur", - "fill.seg_custom": "Eigene" + "fill.seg_custom": "Eigene", + "backup.export_open": "Exportieren", + "backup.import_open": "Importieren", + "backup.undo_import": "Letzten vollständigen Import rückgängig machen", + "gs.bg_mode": "Planhintergrund", + "gs.glow_radius": "Glow-Radius", + "gs.grid_group": "Planwartung", + "gs.light_group": "Beleuchtung", + "gs.lqi_group": "Zigbee-Signal", + "gs.north_ph": "nicht gesetzt", + "gs.optimize_undo": "Letzte Optimierung rückgängig machen", + "gs.radar_show_live": "Live-Präsenz auf dem Plan anzeigen", + "gs.sun_rays": "Sonnenlicht durch Fenster", + "gs.temp_group": "Temperatur", + "gs.title": "Allgemeine Einstellungen", + "gs.wall_fill": "Wandfüllung" } diff --git a/src/i18n/settings/en.json b/src/i18n/settings/en.json index d41a7224..4d390ec2 100644 --- a/src/i18n/settings/en.json +++ b/src/i18n/settings/en.json @@ -55,6 +55,8 @@ "gs.bg_custom_hint": "Custom background color.", "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.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", @@ -91,5 +93,20 @@ "fill.seg_lqi": "Zigbee", "fill.seg_light": "Lights", "fill.seg_temp": "Temperature", - "fill.seg_custom": "Custom" + "fill.seg_custom": "Custom", + "backup.export_open": "Export", + "backup.import_open": "Import", + "backup.undo_import": "Undo last full import", + "gs.bg_mode": "Plan background", + "gs.glow_radius": "Glow radius", + "gs.grid_group": "Plan maintenance", + "gs.light_group": "Lights", + "gs.lqi_group": "Zigbee signal", + "gs.north_ph": "not set", + "gs.optimize_undo": "Undo last optimization", + "gs.radar_show_live": "Show live presence on the plan", + "gs.sun_rays": "Sunlight through windows", + "gs.temp_group": "Temperature", + "gs.title": "General settings", + "gs.wall_fill": "Wall fill" } diff --git a/src/i18n/settings/fr.json b/src/i18n/settings/fr.json index 7edfb2b2..d5cdc479 100644 --- a/src/i18n/settings/fr.json +++ b/src/i18n/settings/fr.json @@ -55,6 +55,8 @@ "gs.bg_custom_hint": "Couleur de fond personnalisée.", "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.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}", @@ -91,5 +93,20 @@ "fill.seg_lqi": "Zigbee", "fill.seg_light": "Lumières", "fill.seg_temp": "Température", - "fill.seg_custom": "Perso" + "fill.seg_custom": "Perso", + "backup.export_open": "Exporter", + "backup.import_open": "Importer", + "backup.undo_import": "Annuler la dernière importation complète", + "gs.bg_mode": "Arrière-plan du plan", + "gs.glow_radius": "Rayon du halo", + "gs.grid_group": "Maintenance du plan", + "gs.light_group": "Éclairage", + "gs.lqi_group": "Signal Zigbee", + "gs.north_ph": "non défini", + "gs.optimize_undo": "Annuler la dernière optimisation", + "gs.radar_show_live": "Afficher la présence en direct sur le plan", + "gs.sun_rays": "Lumière du soleil à travers les fenêtres", + "gs.temp_group": "Température", + "gs.title": "Paramètres généraux", + "gs.wall_fill": "Remplissage des murs" } diff --git a/src/i18n/settings/ru.json b/src/i18n/settings/ru.json index 0f62d350..10992e91 100644 --- a/src/i18n/settings/ru.json +++ b/src/i18n/settings/ru.json @@ -55,6 +55,8 @@ "gs.bg_custom_hint": "Свой цвет фона.", "gs.bg_theme_hint": "Используется фон темы.", "gs.sun_rays_hint": "Рисовать солнечные лучи через оконные проёмы.", + "gs.moon": "Луна на плане в сумерках и ночью", + "gs.moon_hint": "Видна на фоне за планом при высоте от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.", "gs.error_glow_radius": "Введите радиус больше нуля.", "gs.error_north": "Введите целое число от 0° до 359°.", "gs.opacity_of": "Прозрачность: {name}", @@ -91,5 +93,20 @@ "fill.seg_lqi": "Zigbee", "fill.seg_light": "Свет", "fill.seg_temp": "Температура", - "fill.seg_custom": "Свой цвет" + "fill.seg_custom": "Свой цвет", + "backup.export_open": "Экспорт", + "backup.import_open": "Импорт", + "backup.undo_import": "Отменить последний полный импорт", + "gs.bg_mode": "Фон плана", + "gs.glow_radius": "Радиус свечения", + "gs.grid_group": "Обслуживание планов", + "gs.light_group": "Освещение", + "gs.lqi_group": "Сигнал Zigbee", + "gs.north_ph": "не задан", + "gs.optimize_undo": "Отменить последнюю оптимизацию", + "gs.radar_show_live": "Показывать текущее присутствие на плане", + "gs.sun_rays": "Солнце в окнах", + "gs.temp_group": "Температура", + "gs.title": "Общие настройки", + "gs.wall_fill": "Цвет заливки стен" } diff --git a/src/moon-art.generated.ts b/src/moon-art.generated.ts new file mode 100644 index 00000000..f6fce216 --- /dev/null +++ b/src/moon-art.generated.ts @@ -0,0 +1,8 @@ +/* Generated by scripts/generate-moon-assets.mjs. DO NOT EDIT. */ +// Designer pack assets/moon/houseplan-1.0.0 (#661). Lazy: imported only by +// moon-runtime.ts, never by the initial View graph (C7). +// A static Lit template: no directive, no binding, nothing parsed at runtime but the art. +import { svg } from 'lit'; +export const MOON_PACK = "houseplan-1.0.0"; +export const MOON_ART_SHA256 = "f8ca37ae26e4d783e67b1aa2e795cd366a9c0d5fcc979c73c9a1a8dc2a85c29c"; +export const MOON_ART = svg`<defs><radialGradient id="hp-moon-b" cx="0" cy="0" r="1" gradientTransform="matrix(92.389 0 0 91.2858 99.35 100.448)" gradientUnits="userSpaceOnUse"><stop stop-color="#949493"/><stop offset=".23" stop-color="#9a9a99"/><stop offset=".54" stop-color="#abacaa"/><stop offset=".77" stop-color="#bcbdbb"/><stop offset=".9" stop-color="#bdbebc"/><stop offset=".95" stop-color="#c4c4c3"/><stop offset=".99" stop-color="#d0cfce"/><stop offset="1" stop-color="#d6d5d4"/></radialGradient><radialGradient id="hp-moon-c" cx="0" cy="0" r="1" gradientTransform="matrix(100 0 0 100 100 100)" gradientUnits="userSpaceOnUse"><stop offset=".8" stop-color="#f7f7f7" stop-opacity="0"/><stop offset=".855" stop-color="#f7f7f7" stop-opacity=".72"/><stop offset=".9" stop-color="#f7f7f7" stop-opacity=".72"/><stop offset=".905" stop-color="#2b3138" stop-opacity=".5"/><stop offset=".92" stop-color="#2b3138" stop-opacity=".5"/><stop offset=".935" stop-color="#2b3138" stop-opacity="0"/></radialGradient><mask id="hp-moon-a"><circle cx="100" cy="100" r="90" fill="#fff"/></mask></defs><g transform="scale(2.56)"><g mask="url(#hp-moon-a)"><circle cx="100" cy="100" r="90" fill="#d6d5d4"/><path fill="url(#hp-moon-b)" d="M189.3 99.8c0 48.7-40 88.3-89.3 88.3a89 89 0 0 1-89.3-88.3c0-48.7 40-88.2 89.3-88.2a89 89 0 0 1 89.3 88.2"/><g opacity=".5"><g opacity=".5"><path fill="#fff" d="M45.6 65.5c-.3.5 1.7 1.8 1.7 1.8s-1.3 3 0 3.4c1.2.3.5 3.8.5 3.8s1 .4 0 .8-1.8 2.1-2 3.3c-.3 1 3.4 2.7 3.4 2.7s-1.6-.5-1 1.5c.7 1.9 0 .4-.6 1.7s-2.1 1.4-2.1 1.4L44 88.1s-1.8 2.1-3 2.6c-1.4.4.8-2-2-.3-3 1.8.2 1.2-1 .7q-2-.8-2.4 1.2c-.5 1.5-.6.5-.6.5s.5 1.5 1 1.2c.5-.2 2.3 1.5 3.2 2.6s4 8.9 3 9l-4.2 1s-.7 1-1.3 1l-4.5 1.2s-.8.6-1.8.2c-1-.5-4.6.5-4.7-1-.2-1.7-2.5-.5-2.5-.5v-4.8c0-1-3.8-1.3-3.2-3 .4-1.8-3.6-3.7-4.2-4.8s2.4-6.6 3.7-7c1.3-.3 1.4-1.4 1-4.4-.5-3 1.3.8 1.3-.5s-5-3.3-4-4.1c1.2-.8.4-7 .9-8.8.5-2 3.6-9.6 7.4-9 4 .7 4.4 0 4.4 0s2-.2 2.4 0c.5 0 .4-.5 3-1.1 2.5-.6 4.2-2.6 4.7-2.8s2.3-5.2 4.6-5.2c2.4 0 4.3 2 5.5 2.6s.2 4.4.2 5.2-1 4-1 4 2 .8.4 1.6c-1.5.8-3.7-1.4-4.6 0"/><path fill="#b6b6b6" d="M44.2 65.1c-.4.5 1.4 2.3 1.4 2.3s-1.3 2.5 0 2.8 1.5 3.6 1.5 3.6 1 .3 0 .8-1.8 2-2 3.2c-.3 1 1 2.6 1 2.6s-.6-.7 0 1.2c.7 2 .6.5 0 1.7a4 4 0 0 1-1.3 1.8l-2.5 1.8s-1 1.9-2.2 2.4.5-2-2.4-.2c-3 1.8-.8 1.6-2.1 1.1q-1.7-.8-2.3 1.3l-.4 1.1s1.6 1.5 2 1.2c.6-.3 2.3 1.5 3.2 2.6s3.5 7.2 2.5 7.4l-3.9.9s-.2.9-.8.9l-4.4.7s-.9.6-1.8.1-3.9.3-4-1.4c-.2-1.5-1.6-1.7-1.6-1.7s-.6-2.2-.6-3.2-2.6-1.6-2-3.4c.4-1.7-2.4-4-3-5.1s1.7-5 3-5.4c1.3-.3 1.7-.4 1.2-3.5-.5-3 .4-.6.4-1.8s-4.5-3.2-3.4-4 1-5.5 1.5-7.4 1.3-7.8 5.2-7.1c3.9.6 4.4-.1 4.4-.1h2.4c.5 0 .3-.4 3-1 2.5-.7 4-2.6 4.5-2.8.5-.3 1.7-4 4-4 2.4 0 3.8-.3 5 .3s.5 3.4.5 4.2-1 4-1 4 1 .4-.5 1.2c-1.6.9-3.5-.6-4.5.8z"/><path fill="#6d6d6d" d="M42.7 64.8c-.3.5 1.2 2.9 1.2 2.9s-1.4 1.7 0 2 2.5 3.4 2.5 3.4.9.3 0 .8-1.9 2-2.1 3.2c-.2 1-1.4 2.6-1.4 2.6s.6-1.1 1.2.8 1.2.5.6 1.8q-.7 2-.6 2.1L40.5 86s0 1.6-1.4 2.1.1-1.8-2.8 0c-2.9 1.7-1.9 2-3.2 1.4q-1.8-.7-2.2 1.4c-.5 1.5-.2 1.7-.2 1.7s2.6 1.4 3.1 1.1 2.1 1.6 3.1 2.7 2.9 5.6 2 5.7l-3.6.9s.3.8-.3.8l-4.3.3s-.8.5-1.8 0-3.2 0-3.4-1.6l-.5-3s-1.4-.7-1.4-1.7-1.3-2-.8-3.8-1-4.3-1.7-5.5c-.7-1.1 1-3.4 2.3-3.7s2 .5 1.5-2.5-.5-2-.5-3.3-4-3-3-3.8c1.2-.8 1.7-4 2.2-6s-.9-6 3-5.3c3.9.6 4.4-.1 4.4-.1h2.4c.5 0 .3-.4 2.9-1s4-2.6 4.4-2.8c.5-.3 1-2.5 3.4-2.5s3.3-2.9 4.4-2.2.9 2.4.9 3.2c0 .7-1 4-1 4l-1.5.8c-1.5.8-3.2.2-4.2 1.6"/><path fill="#404040" d="M41.8 64.6c-.4.5 1 3.2 1 3.2s-1.4 1.3 0 1.6c1.2.3 3 3.2 3 3.2s1 .3 0 .8c-.8.5-1.8 2.1-2 3.2-.3 1.1-3 2.6-3 2.6s1.3-1.3 2 .6c.6 2 1.5.5 1 1.8s-.3 2.4-.3 2.4l-4.2 1.2s.5 1.5-.8 2-.2-1.8-3 0c-3 1.7-2.7 2.2-4 1.7-1.3-.4-1.7 0-2.2 1.5-.5 1.4 0 2 0 2s3.2 1.4 3.7 1.1 2.1 1.7 3 2.8c1 1.1 2.6 4.5 1.7 4.6l-3.3.8s.6.8 0 .8H30s-.8.5-1.8 0-2.8-.3-2.9-1.9 0-3.8 0-3.8-1.8.3-1.8-.7-.5-2.2 0-4-.3-4.6-1-5.7.7-2.4 2-2.7 2.1 1 1.6-2-1.1-2.8-1.1-4.1-3.8-2.9-2.6-3.7 2.1-3.2 2.6-5.1c.5-2-2.3-4.8 1.6-4.2 3.9.7 4.4 0 4.4 0h2.4c.5 0 .3-.4 2.9-1s3.9-2.7 4.4-2.9c.4-.2.6-1.7 3-1.7 2.3 0 2.9-4.3 4-3.6s1 1.7 1 2.5-1 4-1 4-.4-.1-2 .7c-1.5.8-3 .6-4 2"/></g><g opacity=".6"><path fill="#fff" d="M88.9 63.3c.7-2.3 0-.6 1-.3s3.4 3.6 5 2.1.7 0 3.7-1.1 3.2-.2 4.3-1.6c1.2-1.5 1.9-1.5 3-2.9s-1.1 2 1.2-1.4 2.6-6.2 3-7.8c.5-1.6 1-5.3 0-6.2s-.8-4.8-1.3-6-10-3.1-10-3.1 0 1.6-2.3 1-.9-.4-2.3-.6l-3-.5s-.7.5-1.4 0-2.3-1.1-3.7 0-4 2.6-4 2.6c-4.4 2.7-8.3 4-8.3 4s2.4.7 0 1.8c-2.3 1.2-2.5.5-2.5 2.2V47s3.2.4 0 1.1-3.5-2.6-3.5-2.6-1.3 1.8-2.2.6l-1-1.1-2.5 1.7s-2.1.9-2.1 2-1.2.8-2.8 1.5c-1.7.7-2.7 1.6-2.7 1.6l-3.6 2.3s-1.2 4.4-1 5.5c.3 1.2-2.1 2-1.3 2.8s-1 2.2-1.4 3.8c-.5 1.6.5 3 .7 5 .3 2.1 2 5 2 5s2.5-1.9 3.4-1a9 9 0 0 1 2.2 3.2c.9 1.8.6.7.9 2.5.2 1.8.8 2 .8 2s0-2.2.8.8c.7 3 2.3 4.7 6 4.2s3.3-3.7 3.3-3.7 4-3.4 5.9-2.5c1.8.9 2.7.4 4.8-.3s2-1.8 3.2-3.8c1.2-2.1.6-4.2 2.6-4.2 2.1 0 4.1-1.3 4.1-1.3s7.1-1.9 5.5-3.3c-1.7-1.3-3-1.8-2.7-3.2.3-1.3-1.9-2.4-1.9-2.4"/><path fill="#b6b6b6" d="M87 62.8c.6-2.3 1-1.6 2-1.3 1 .2 4 3.3 5.5 1.9 1.7-1.5.6-.2 3.6-1.3s3 1 4.2-.4c1.1-1.5 1.8-1.5 3-2.9s-1.2 2 1.1-1.4c2.4-3.4 2.6-6.2 3-7.8.5-1.6 1-5.3 0-6.2s-1-3.7-1.5-5-8.4-2.3-8.4-2.3 0 1.6-2.4 1c-2.3-.5-.9-.3-2.3-.5l-3-.5s-.7.4-1.4 0-2.3-1.2-3.7 0-4 2.5-4 2.5a36 36 0 0 1-6.8 3.6s2.3.6 0 1.8c-2.3 1.1-3 1-3 2.6v1.6s3.3.5 0 1.2-3.9-.7-3.9-.7-1.2.9-2.2-.3l-.9-1.1-3 .3s-1.7.4-1.7 1.6c0 1.1-.9 1.2-2.5 1.9s-3 2-3 2l-4 1.4s-.7 2.6-.4 3.8-1.7 2.5-1 3.1c.9.7 0 1.7-.5 3.3s-1.3 2.6-1 4.7C49 71.4 50 74 50 74s2-1.5 3-.6 2.6 1 3.5 3c1 1.7.7.4 1 2.2.2 1.9.5 2.3.5 2.3s.3-2 1 1 .6 5.1 4.3 4.6 3.4-2.9 3.4-2.9 4-3.4 5.9-2.5c1.8 1 2.7.4 4.8-.2s2-1.9 3.2-4c1.2-2 .6-4 2.6-4 2.1 0 4.2-2.7 4.2-2.7s5.5-.8 3.9-2.2-2.5-1.1-2.3-2.5-2-2.7-2-2.7"/><path fill="#6d6d6d" d="M85 62.3c.6-2.3 2.2-2.6 3.2-2.3.9.2 4.3 3 6 1.6 1.6-1.5.3-.3 3.3-1.4s2.9 2.2 4 .7c1.2-1.4 1.9-1.4 3-2.8s-1 2 1.2-1.4c2.4-3.4 2.6-6.2 3-7.8.5-1.6 1-5.3 0-6.2s-1.2-2.7-1.7-4-7-1.5-7-1.5 0 1.7-2.3 1c-2.3-.5-.9-.3-2.3-.5l-3-.5s-.7.5-1.4 0-2.3-1.2-3.7 0-4 2.5-4 2.5C79 42.5 78 42.8 78 42.8s2.3.7 0 1.8-3.4 1.5-3.4 3.2v1.6s3.2.4 0 1.1-4.4 1.3-4.4 1.3-1.2 0-2.1-1.2l-1-1.2-3.4-1.1s-1.4 0-1.4 1.2c0 1-.4 1.7-2 2.4s-3.4 2.3-3.4 2.3l-4.2.6s-.4.8-.2 2-1.2 3-.4 3.6.7 1 .3 2.7c-.5 1.6-3.1 2.2-2.9 4.3l.5 4.3s1.7-1.1 2.6-.2 4 .8 5 2.6.6.2.8 2l.4 2.8s.4-2 1 1c.8 3-1.1 5.5 2.6 5s3.6-2 3.6-2 4-3.5 5.8-2.5c1.9.9 2.8.4 4.9-.3 2-.7 1.9-1.8 3.2-3.9 1.2-2 .5-4.1 2.6-4.1 2 0 4.1-4 4.1-4s4 .3 2.4-1.1-2.1-.4-1.9-1.8-2.2-2.9-2.2-2.9"/><path fill="#404040" d="M83.7 62c.7-2.3 3-3.2 4-3 .8.3 4.6 3 6.2 1.5s.2-.4 3.2-1.5 2.8 3 4 1.5c1.1-1.5 1.8-1.5 3-2.8 1.2-1.4-1.1 2 1.2-1.4 2.3-3.5 2.5-6.2 3-7.8s1-5.3 0-6.2q-1.3-1.4-1.9-3.3c-.4-1.3-6-1-6-1s0 1.6-2.3 1c-2.4-.6-1-.4-2.4-.6l-3-.5s-.7.4-1.4 0-2.3-1.2-3.7 0-4 2.5-4 2.5l-4.4 2.8s2.4.7 0 1.8c-2.3 1.1-3.7 1.9-3.7 3.4v1.7s3.3.4 0 1.1a8 8 0 0 0-4.6 2.5s-1.2-.7-2.1-1.8l-1-1.2-3.7-2s-1.1-.2-1.1 1c0 1-.3 2-2 2.7s-3.4 2.5-3.4 2.5h-4.4s-.3-.3 0 .9-1 3.2-.1 4c.8.6 1.3.6.8 2.2s-4.2 2-4 4.1 0 4.2 0 4.2 1.4-1 2.4 0 4.8.7 5.8 2.5.7 0 1 1.8l.2 3s.4-1.8 1.1 1.1-2.3 5.8 1.4 5.3 3.7-1.6 3.7-1.6 4-3.4 5.8-2.5c1.9 1 2.8.4 5-.3 2-.6 1.8-1.8 3-3.8 1.3-2.1.6-4.2 2.7-4.2s4.2-4.8 4.2-4.8 3 1 1.4-.4-1.9 0-1.6-1.4-2.4-3-2.4-3"/></g><g opacity=".6"><path fill="#fff" d="M40.9 36.2s-6.7 3.4-7.2 6c-.5 2.7-3 9.2-3 11.1s1.4 4.4 2.1 5.4 3.8-3.8 7.4-3.8 4.4-.5 5.1-1.5c.8-1 4-3.1 5.1-4 1.2-.8 3.7-5.3 4.4-5.8s4.5-7 1.9-7c-2.7 0-5.2-2-6.9-2.7s-7.7 1.4-9 2.3"/><path fill="#b6b6b6" d="M39.9 38.3s-4.9 2.4-5.4 5C34 46 32.2 50 32.2 52s1.3 3.6 2 4.6 1.7-2.4 5.3-2.4c3.7 0 4.4-.4 5.1-1.4s3.9-3.2 5.1-4 3.6-5.4 4.4-5.8c.7-.5 2.9-5.7.2-5.7s-4-1-5.7-1.7-7.5 1.9-8.7 2.8"/><path fill="#6d6d6d" d="M39 40.4s-3.2 1.4-3.6 4c-.5 2.6-1.8 4.2-1.8 6 0 2 1.2 3 2 4 .7.9-.5-1 3.2-1 3.6 0 4.3-.5 5-1.4.8-1 4-3.2 5.2-4s3.6-5.4 4.3-5.9 1.3-4.4-1.4-4.4c-2.6 0-2.8.1-4.6-.6s-7.2 2.4-8.4 3.3"/><path fill="#404040" d="M38.3 41.7s-2 .7-2.4 3.3c-.5 2.7-1.5 2.7-1.5 4.6 0 2 1.2 2.4 2 3.3.7 1-1.8 0 1.9 0 3.6 0 4.4-.4 5-1.4.8-1 4-3.2 5.2-4s3.6-5.4 4.3-5.8c.8-.5.3-3.6-2.4-3.6s-2.2.7-3.9 0c-1.7-.8-7 2.6-8.2 3.6"/></g><g opacity=".6"><path fill="#fff" d="M41.3 87c-1.4.7-.8 2.3-1 2.7-2.7 3.9.5 5.6.5 6.8 0 1-3 4.4.1 9.9 2.7 4.5 4.6-.4 5.6-.2s0 1.9 1.6.5.5-1.3 1.6-1.3 2.8.6 3-.5c0-1 1.2-1.3 1.5-2.2s3.7-3 3.2-3-2.8-2.1-2.3-3.9.2-4.4-1-4.7c-1-.3-2.7-5.2-3.3-4.7s-3.7-2-9.5.7"/><path fill="#b6b6b6" d="M41.8 89.2c-.9.8-.4 2.9-.6 3.3-1.4 2.7 0 4 0 5.2 0 1-2 4 .3 7.4 1.8 2.8 3.3.2 4.2.4 1 .1 0 1.8 1.6.5s.5-1.3 1.6-1.3 2.8.6 3-.5c.1-1.1 1.2-1.3 1.5-2.2s2-3.5 1.5-3.5-2.6-1.6-2-3.3c.4-1.8 1-4.2-.2-4.6-1-.3-2.8-3.4-3.4-2.9s-4.4-1.4-7.5 1.5"/><path fill="#6d6d6d" d="M42.4 91.3c-.2 1.3-.1 3.5-.2 3.9q-.7 2-.7 3.7c0 1-.7 3.6.5 5 1 1 2 .7 3 .8s0 2 1.6.6.5-1.3 1.6-1.3 2.8.6 3-.5c0-1.1 1.2-1.3 1.5-2.2s.3-3.8-.2-3.8-2.4-1.2-2-3c.6-1.7 2-4 .9-4.3s-2.9-1.6-3.5-1c-.6.4-4.3.1-5.5 2.1"/><path fill="#404040" d="M42.8 92.6c.2 1.5 0 3.8 0 4.3s-1 1.6-1 2.7 0 3.4.6 3.4 1.2 1.1 2.2 1.3 0 1.9 1.6.5.5-1.3 1.6-1.3 2.7.6 2.9-.5c.2-1 1.3-1.2 1.6-2.2s-.8-4-1.3-4-2.3-1-1.8-2.7 2.4-3.9 1.3-4.2-2.8-.5-3.4 0-4.6 1-4.4 2.7"/></g><g opacity=".4"><path fill="#fff" d="M49.7 105.3c-1 1.9-3.8 8.2 2 8.2.8 0 1.4-.6 2.2-.8s-.5-.6.7-2.6c1.6-3.2-4.9-4.8-4.9-4.8"/><path fill="#b6b6b6" d="M50 106.4c-1 1.9-3 6 .9 6.4.9 0 1.5-.7 2.3-.8s.4-.7.6-2.7c.3-2.6-3.8-2.9-3.8-2.9"/><path fill="#6d6d6d" d="M50.3 107.5c-1 1.8-2 4.1-.1 4.5.8.2 1.4-.6 2.2-.8.9-.1 1.4-.7.7-2.6-.8-2.1-2.8-1.1-2.8-1.1"/><path fill="#404040" d="M50.5 108.1q-1.8 2.9-.8 3.5c.8.3 1.5-.7 2.3-.8s2-.8.6-2.7c-1.5-1.8-2.2 0-2.2 0"/></g><g opacity=".45"><path fill="#fff" d="M68.8 86.4c-1.7.2-3.6.9-4 1.5s-4.7-.9-5 .5C59.7 90 56 93 56 93s3.9 5.2 4.3 6.1c.5 1 5-.2 5.9-1 .8-.8.3-1.6 1.3-1.7 1-.2 1.7-.7 3-.4 1.3.4 5.2 1.6 5.9 1 .6-.7 1.1-3.4 1.1-3.4l1.6-2.9s-2-7.6-4.3-5.8c-2.5 1.7-6 1.6-6 1.6"/><path fill="#b6b6b6" d="M67.6 87.5c-1.8.1-3 1-3.3 1.7-.4.7-4-.7-4.2.8-.2 1.4-2.4 3.6-2.4 3.6s2.7 2.9 3.2 3.8c.5 1 3.7.7 4.6-.1s.3-1.6 1.2-1.8c1-.1 1.8-.6 3.1-.3s5.2 1.6 5.9 1c.6-.7 1-3.4 1-3.4l.4-2.5s-1.7-5.6-4.2-3.8c-2.4 1.7-5.3 1-5.3 1"/><path fill="#6d6d6d" d="M66.3 88.5c-1.8.2-2.3 1.3-2.7 2-.3.6-3-.5-3.2 1-.2 1.4-1 2.8-1 2.8s1.5.5 2 1.5 2.4 1.5 3.3.7.3-1.6 1.3-1.7c1-.2 1.7-.7 3-.4 1.3.4 5.2 1.6 5.9 1 .6-.7 1-3.4 1-3.4l-.9-2s-1.6-3.7-4-2-4.7.5-4.7.5"/><path fill="#404040" d="M65.5 89.2c-1.7.2-1.9 1.4-2.2 2s-2.5-.2-2.7 1.2a7 7 0 0 0 0 2.4s.7-1 1.2 0 1.6 2 2.4 1.3c.9-.8.4-1.6 1.3-1.8s1.8-.6 3.1-.3 5.2 1.6 5.9 1c.6-.7 1-3.4 1-3.4l-1.7-1.8s-1.5-2.4-3.9-.6-4.4 0-4.4 0"/></g><g opacity=".45"><path fill="#fff" d="M94.1 61.7c-1-.4.3 1.5.1 2.8s2.3 1.5 1.8 2.4-2.4-1.6-3-.9c-.7.9-6 3.6-5 3.9s1.7 7.3 1.7 7.3 4.3 2.1 5.5 2.3c1.1.1-.6 2.5 2.7 2.5 3.2 0 3.1-.5 3.9-1.1a8 8 0 0 1 4-2c1.8-.1 7 .4 7.3-.6l1.1-4s1.5 1 1.4-.6 3.2-3.9.9-4.2-1.2-1-1.5-2.3l-.5-3.2c-.2-.4-2.9-1.6-3.8-1.8-1-.1-2.8-1.8-2.8-1.8l-3.7.2h-2.8s-5 2-7.3 1.1"/><path fill="#b6b6b6" d="M95.3 62.7c-1-.5 1 1.3.8 2.6-.2 1.2.8 1.6.3 2.4s-2.2-1-2.8-.2c-.7.8-5.6 3-4.6 3.4s1.5 5.6 1.5 5.6 2.8 2.1 4 2.3c1 .1-.4 1.3 2.8 1.3 3.3 0 3 .7 3.8 0a8 8 0 0 1 4-1.8c1.8-.2 7 .3 7.3-.7l1.1-4s1 .9.9-.7c-.2-1.5 3-3.1.6-3.4s-1.8-1-2.1-2.3-.4-2.5-.6-2.8a8 8 0 0 0-3-1.9c-1-.2-2.6-.1-2.6-.1l-3.3-.3-2.1-.6s-3.7 2.1-6 1.2"/><path fill="#6d6d6d" d="M96.4 63.6c-1-.5 1.8 1.2 1.6 2.4-.2 1.3-.7 1.8-1.2 2.6s-2-.5-2.6.3-5.2 2.6-4.2 3 1.2 3.9 1.2 3.9 1.3 2 2.5 2.2c1 .2-.3 0 3 0s2.8 2 3.6 1.5a8 8 0 0 1 4-2c1.8-.1 7 .3 7.3-.6l1.2-4s.4.8.3-.8 2.6-2.5.2-2.8c-2.3-.3-2.3-.8-2.6-2.1l-.6-2.4c-.2-.3-1.4-2-2.4-2-1-.2-2.4 1.4-2.4 1.4l-2.9-.6-1.3-1.3s-2.4 2.2-4.7 1.3"/><path fill="#404040" d="M97.1 64.2c-1-.5 2.3 1 2.1 2.3s-1.6 1.8-2.1 2.6-1.8-.2-2.4.7c-.7.8-5 2.3-4 2.7.9.3 1 2.8 1 2.8s.4 2.1 1.5 2.3-.2-.9 3-.9 2.8 3 3.6 2.3a8 8 0 0 1 4-2c1.9-.1 7 .4 7.4-.6l1-4s.3.7 0-.8c0-1.6 2.5-2 .2-2.4-2.4-.3-2.7-.8-3-2q-.4-1.8-.7-2.1c-.1-.4-1-2.1-2-2.3s-2.2 2.6-2.2 2.6l-2.6-1-1-1.6s-1.5 2.3-3.8 1.3"/></g><g opacity=".2"><path fill="#fff" d="M83.7 132c-2.2-.8.5 3.1.1 5.8-.3 2.6 4.7 3.2 3.8 4.8-1 1.7-5.1-3.2-6.5-1.6-1.3 1.6-12.1 7.2-10.1 7.9s3.4 15 3.4 15 9 4.3 11.4 4.7c2.3.3-1 5.3 5.6 5.3s6.4-1 8-2.4c1.7-1.3 4.7-3.6 8.4-4 3.6-.2 14.3.7 15-1.2l2.3-8.3s3 2 2.7-1.3 6.7-7.9 1.9-8.5-2.4-2.1-3.1-4.8-.7-6-1-6.7a25 25 0 0 0-7.8-3.5c-2-.3-5.7-3.7-5.7-3.7l-7.7.4h-5.7s-10.3 4.1-15 2.2"/><path fill="#b6b6b6" d="M86 134c-2.2-1 2 2.7 1.7 5.4-.3 2.6 1.7 3.3.7 5-1 1.6-4.5-2.2-5.9-.5-1.3 1.6-11.4 6.3-9.4 7 2 .6 3 11.6 3 11.6s5.8 4.3 8.2 4.6-.8 2.7 5.8 2.7c6.7 0 6.2 1.5 7.8.2 1.7-1.3 4.7-3.6 8.3-4 3.7-.2 14.4.8 15-1.2.7-2 2.4-8.2 2.4-8.2s2 1.7 1.7-1.5 6-6.5 1.2-7.2c-4.8-.6-3.6-1.9-4.3-4.5-.7-2.7-.8-5.1-1.1-5.8s-4.4-3.6-6.4-4-5.3-.3-5.3-.3l-6.8-.5-4.3-1.2s-7.6 4.3-12.3 2.3"/><path fill="#6d6d6d" d="M88.3 135.8c-2.1-.9 3.7 2.5 3.4 5.1-.4 2.7-1.5 3.6-2.5 5.2s-4-1-5.3.6-10.7 5.5-8.7 6.2c2 .6 2.6 8 2.6 8s2.6 4.3 5 4.7c2.3.3-.5 0 6.1 0 6.7 0 5.8 4.2 7.5 2.9s4.7-3.6 8.3-4 14.3.7 15-1.3 2.3-8.2 2.3-8.2 1 1.6.7-1.6 5.3-5 .5-5.7-4.7-1.8-5.4-4.4l-1.2-4.9c-.4-.6-3-4-5-4.3-2-.4-4.8 3.1-4.8 3.1l-6-1.4-2.8-2.5s-5 4.5-9.6 2.5"/><path fill="#404040" d="M89.8 137c-2.2-.9 4.7 2.3 4.3 5-.3 2.6-3.3 3.6-4.3 5.2s-3.7-.3-5 1.3-10.3 5-8.3 5.6c2 .7 2.3 6 2.3 6s.7 4.2 3 4.5c2.3.4-.3-1.6 6.3-1.6 6.7 0 5.7 5.9 7.3 4.6 1.7-1.3 4.7-3.6 8.4-4 3.6-.3 14.3.7 15-1.3l2.3-8.2s.3 1.5 0-1.7c-.4-3.2 5-4.2.1-4.9-4.8-.6-5.4-1.6-6.1-4.2a22 22 0 0 0-1.3-4.3c-.4-.7-2-4.3-4-4.6s-4.7 5.3-4.7 5.3l-5.4-2-2-3.3s-3.2 4.6-7.9 2.6"/></g><g opacity=".4"><path fill="#fff" d="M81 22q-.8.1-5.4 1.6c-3 1-7.2 5-7.2 6.7s9.8 3 10.6 3.5 4.7.9 5.5 0c.8-.7 2.4-1.4 3-1.5s3.2-2.4 3.2-2.4l-.9-1.5s0-2.4-.5-2.2-3.5-1.9-3.5-1.9-3.1-2.1-5-2.3z"/><path fill="#b6b6b6" d="M80 23.4c-.6 0-1.1.4-4.2 1.4-3 1-5.6 2.7-5.6 4.5s7.8 2.8 8.6 3.3 4.2 1.4 5 .6 2.4-1.5 3-1.6 1.7-2.4 1.7-2.4l-.9-1.8s-.3-1.5-.8-1.3-2.9-1.6-2.9-1.6-2.1-.9-4-1"/><path fill="#6d6d6d" d="M79 24.8c-.6 0 0 .2-3 1.2s-4 .5-4 2.3 5.7 2.5 6.5 3 3.8 2 4.5 1.1c.9-.8 2.5-1.4 3.1-1.6.7-.1.2-2.3.2-2.3l-1-2.1s-.5-.7-1-.5-2.3-1.3-2.3-1.3-1.2.4-3 .3"/><path fill="#404040" d="M78.4 25.7c-.6 0 .8 0-2.3 1s-3-.8-3 1c0 1.7 4.5 2.3 5.3 2.8s3.4 2.2 4.2 1.4 2.4-1.4 3-1.6c.7-.1-.8-2.4-.8-2.4l-1-2.2h-1c-.5.2-2-1.1-2-1.1s-.7 1.3-2.4 1.1"/></g><g opacity=".4"><path fill="#fff" d="M111.9 54c-.1.5-6.8 3.2-5 6s4.1 2.8 6.4 3.2c2.2.3 1.9-1.8 3-2.4s1 2.5 1.2-.5 1.3-5.3-.5-5.5c-1.7-.1-5.1-.8-5.1-.8"/><path fill="#b6b6b6" d="M111.3 55c-.1.5-5.2 1.7-3.4 4.4s2.4 2.7 4.7 3c2.2.3 1.9-1.7 3-2.3s1 2.5 1.2-.6c.1-3 .5-3.8-1.3-4z"/><path fill="#6d6d6d" d="M110.7 56c-.1.5-3.5.1-1.8 2.9 1.8 2.7.6 2.5 3 2.8 2.2.3 1.9-1.8 3-2.3 1.1-.6 1 2.4 1.1-.6s-.3-2.5-2-2.6z"/><path fill="#404040" d="M110.3 56.6c0 .5-2.5-.8-.7 1.9 1.7 2.7-.5 2.4 1.7 2.7s2-1.7 3.1-2.3 1 2.5 1.1-.6c.2-3-.8-1.6-2.6-1.7z"/></g><g opacity=".6"><path fill="#fff" d="M152.2 141c-1.8.7-4.6 2.6-3.1 2.9s1.3 1.6 1.3 1.6-1.7 1.3 0 2c1.8.9-3.5-3-3.5-1.5 0 1.4 3.8 4.8 4 6.3.1 1.4-4.4.6-5 1.5-.7 1-.5.3-1.7 1.8-1.3 1.4 1.8 4.3-.4 5.7s-2.6 4.5-4.5 3c-2-1.7 0 4-2 4s-4-.4-5-.4-9.8.3-9-1.8c.9-2-.3-10.4-3.9-12-3.5-1.6 2.5-11.8 4-11.8 1.4 0-.2-4.4.2-5.7.3-1.3 5.7 4.8 6 3.5 0-1.3 3.9-2.2.3-2.7-3.5-.5.7-.5.5 1.4s-3 2.4-3.7 3.2c-.6.8.7 0-1 2-1.6 2.1-.8 1.6-2 3.6-1.4 1.9-.4 1.9 0 4.3.3 2.4 2.4 4.3.4 5-2 .9-1 .9-2.7 1-1.8.2-5.8.4-5.7-1 .2-1.2-9.7-2-9.2-2s-.5-1-.4-2.9c.2-2-1.7-.2-4.1-.2-14.7.1 3.1-2.6.4-2.5s-4-1.4-4-1.4l-.8-1.1c-.3-.5 3.3 0 3.5-1.5.1-1.6.8-2.3-1.1-3-2-.6 1-2.2-.5-2.7a23 23 0 0 1-6-7.4c0-.8-1.4-3.5 0-3.7l3.8-.6q2 0 2.4-1.8l.5-2.4c.2-1.4-1.2-6-1.2-6s1.3-2.2 1.8-3.4l3-5.6s1 1.5 1.7 1.2 1.7-1.4 1.7-2.2 2.1-1 4-1.8c2-.8.2-.3.9-2 .6-1.8.3-.2-.3-2.1s.1-1.8-.7-2c-.8 0 1-.2 1-1.6s-.6 1.3-3 0c-2.5-1.3 1.6-2-1.3-1.4-3 .4-8.8 2.2-9.7-.5-1-2.7.6-7 1.9-8.5s5.7-2.5 5.7-2.5l5-1.5s2 .8 2.5 0c.6-.7-.8-1.8-.8-2.3s.3-1-.2-2.3-1.6-2.5-1.3-3c.2-.5 2.4-2.3 4.7-2.5 2.2-.1 3.3-2.3 3.3-2.3h2.3c1.3-.2-1.4-3.7-2.2-4s-1.4-6.1-1.4-6.1-3.1-1.9-.6-1.9c2.6 0 1.5.1 3.3-.3s-1 0-.1-1.7 3.2-.6 3.2-.6 2 .4 1.7-1.2 2.1-1.5-1.5-1.7c-3.5-.1-1.3-1.4-1-2.5.2-1.2-5.7.8-5.7.8s1.4-2-2.2-1.6c-3.5.3-3.2-5-3.3-7.3-.1-2.4 2.5-8.5 2.5-8.5l6.3-.7c1.3-.2 2.9-2 4.9-2.6s2.7-2.3 4-2 4.8.3 4.8.3 0 .7 2.5 1.7c3.6 1.4 10 1.4 11.2 2 1 .6 4 3 4.2 3.1s-.8-.3-1 2.6 5.3 1 5.5 2.3 1.3 2.8 2 3 2.2-.2 3.5 1c1.3 1 3.4 4.3 3.4 4.3s2 1.6.4 2.9-2 5.4-1 6.4c1.2 1-1.8 2.3-1.8 2.3s1-1.9 1 .4 1.6 0 1 .8c-.4.8.3-2.4.3-2.4s2 3.2 1.7 2.4-3.3-7.9-3.3-9.5c0-2 1-1.8.8-3.7q-.8-3-.2-3a82 82 0 0 1 13.4 10.4 55 55 0 0 1 5.7 15.2c.2 2.1 1 3.7 2.2 7 1.1 3.4 6.1 16.8 1.8 11.5-.6-.7-3.8 6.9-3.6 8.5s3.2 6.8 3.2 9.8-1.6 8.7-2 10c-.6 1.2-1.7 2.2-1.8 3.7-.2 1.4-2.8-2-3.4.5-.7 2.4-1.3-1.8-1.5 1q0 3.8.2 4.6c0 .6-2.3 1.9-3 3-.9 1.1-2.7 1-3.6 2-1 .9 0 .4.3 2s-2.4 2.7-3.6 3.7c-1.1 1-.8-.2-2 1.6-1 1.7-3.6 2.2-3.6 2.2-.9.8-2.5-.5-2.5-.5s-6-1.7-5.8-3.2c.2-1.4-3-.8-3-1.3s-.7-2.8-2.3-4.7 4.2-3 1.6-2.8 4.5-2.2 1.8.5-5.3 1.4-5.9 2.2c-.4.8-.8 2.3-.4 3 .3.9 5.8 4.7 5.8 4.7"/><path fill="#b6b6b6" d="M148.6 138.2c-1.7.6-4.5 2.5-3 2.8 1.4.3 1.3 1.6 1.3 1.6s-1.8 1.3 0 2.1c1.7.8-.7-1.6-.7-.2s1.7 3.8 1.9 5.2-2.4 1.7-3 2.7-.4.3-1.7 1.7c-1.2 1.5.6 3-1.7 4.4s-2.1 4.7-4 3c-2-1.5 0 1.5-2 1.5l-4.3-.3c-1 0-7.2.4-6.4-1.7.8-2 1-7.5-2.5-9.1s.4-9.3 1.8-9.3c1.5 0 2-2.6 2.3-3.8.3-1.3 4 1.9 4.2.6.1-1.3 3.5-3 0-3.4-3.6-.5-1-.6-1.1 1.4-.2 1.9-3 2.4-3.7 3.2s.6 0-1 2c-1.6 2.1-.8 1.6-2.1 3.6-1.3 1.9-.3 1.9 0 4.3s2.4 4.3.5 5c-2 .9-1 .9-2.8 1-1.8.2-5.8.4-5.6-1 .1-1.2-6.5-3.2-6-3.2s.8.1 1-1.8-.9-.7-3-1c-9.6-1.9 2.3-2.5-.4-2.3-2.8.1-5-1.2-5-1.2l-.8-1.1c-.3-.5 1.3 0 1.4-1.6.2-1.6.8-2.2-1.1-2.9s-.1-1.7-1.6-2.2-4.7-5-4.7-5.9c0-.8-1.5-3.5 0-3.7 1.5-.1 2.4-.6 3.7-.6q2.1 0 2.4-1.8c.2-1.2.3-.5.4-2 .2-1.4-.8-5.3-.8-5.3s.4-2.3 1-3.4c.4-1.1 2.5-5 2.5-5s2 2 2.7 1.6c.6-.3 1.8-1.4 1.8-2.2q0-1.2 3-2.5c2-.8.8-.7 1.4-2.5.7-1.8 1.5-.8.9-2.7s-.7-2.5-1.6-2.6c-.8-.2 0-.7 0-2.2 0-1.4.6.7-1.8-.5-2.4-1.3.6-2.2-2.3-1.8s-7.2 3.2-8.1.5c-1-2.7-.6-5.6.7-7 1.3-1.6 4.6-2.5 4.6-2.5l5.5-.9s1.9.2 2.5-.6c.5-.8 0-1.9 0-2.3 0-.5.3-1-.1-2.3s-1.7-2.5-1.4-3a6 6 0 0 1 4.2-2.5c2.2-.1 3.6-2.3 3.6-2.3q.2.2 2.3 0c1.3-.2.2-3.1-.6-3.4s-2.2-5-2.2-5-3-3.5-.4-3.5 2.5.3 4.3-.2-.2.5.6-1.2c.8-1.8 1.3-2 1.3-2s1.4-.3 1-1.9 1.9-2.3-1.7-2.5c-3.5-.1-2.6-1.5-2.4-2.6s-3.5 0-3.5 0 1-1.6-2.5-1.3c-3.6.3-3.6-2.5-3.7-4.9s2.5-7.7 2.5-7.7l5-.4c1.2-.2 1.7-1.3 3.7-2s2.9-1.8 4.2-1.6c1.3.3 5.7-.3 5.7-.3s.5 1.2 2.6 1c2.6-.1 6.8.7 8 1.3l4.7 2.5c.1.1.6-.4.4 2.5s4.2 1.5 4.4 2.8 1 2.5 1.6 2.6 2.6-.2 3.9 1c1.3 1 3.7 4 3.7 4s3.4 1.6 1.8 2.8c-1.6 1.3-2.8 3.8-1.7 4.8s-.6 3-.6 3 .7-1.6.7.6 1.7 1.3 1.3 2.1c-.5.8 1.4-.4 1.4-.4s2.5 2.2 2.2 1.4c-.4-.8-1.4-6.2-2-7.4-.9-1.4.3-1.9 0-3.8-.3-2-.7-3-.2-3s8.6 5.8 10.7 8.5 4.4 10.4 4.6 12.5.7 2.5 1.8 5.9c1.2 3.3 4.7 12.7 2 9.8-.2 0-2 6.5-1.8 8.1s1.6 7.7 1.6 10.7-.4 7.1-1 8.4c-.4 1.3-1.4 2.8-1.5 4.2s-1.7-1-2.3 1.4c-.7 2.4-2 .8-2.2 3.5q0 3.9.2 4.6c0 .7-2.3 2-3 3-.9 1.2-2.7 1-3.6 2-1 1-1.5.5-1.2 2 .3 1.7-1 2.8-2 3.8-1.2 1-.9-.2-2 1.5-1.1 1.8-3.7 2.3-3.7 2.3-.8.8-2.5-.5-2.5-.5s-3.7-1.8-3.6-3.2-2.3-1.5-2.3-2 .1-2.2-1.5-4c-1.6-2 2.7-3.1.1-2.8-2.5.3 2.3-2.2-.4.5s-5.3 1.4-5.8 2.2-.8 2.2-.5 3 3.1 3.3 3.1 3.3"/><path fill="#6d6d6d" d="M145.1 135.3c-1.8.6-4.5 2.6-3 2.9 1.4.3 1.3 1.6 1.3 1.6s-1.8 1.2 0 2c1.7.8 2-.2 2 1.3 0 1.4-.3 2.6-.1 4q.1 2.3-1 3.8c-.6 1-.4.3-1.6 1.7-1.3 1.5-.8 1.6-3 3-2.3 1.5-1.8 4.8-3.7 3.3-2-1.6 0-1.3-2-1.3h-3.6c-1 0-4.5.4-3.7-1.7.8-2 2.4-4.7-1.1-6.3-3.6-1.5-1.8-6.8-.3-6.8s4-.7 4.2-2c.4-1.2 2.4-.9 2.6-2.2.1-1.2 3-3.7-.6-4.1-3.5-.5-2.4-.5-2.6 1.4-.1 2-3 2.4-3.6 3.2s.6 0-1 2c-1.7 2.2-.9 1.7-2.2 3.6s-.3 2 0 4.3c.4 2.4 2.5 4.3.5 5.1s-1 .8-2.7 1-5.9.3-5.7-1-3.3-4.6-2.8-4.6 2.3 1.3 2.5-.6c.2-2-.1-.6-2-2-4.7-1.3 1.6-2-1.1-2-2.8.2-6-.9-6-.9l-.8-1c-.3-.6-.7 0-.5-1.7s.8-2.2-1.1-2.8c-2-.7-1.4-1.3-2.9-1.8-1.4-.5-3.4-3.5-3.4-4.3s-1.4-3.5 0-3.7c1.5-.2 2.5-.6 3.8-.6q2 0 2.4-1.8l.2-1.5c.2-1.5-.5-4.9-.5-4.9s-.4-2.2 0-3.3c.5-1.2 2.4-4.3 2.4-4.3s3 2.3 3.6 2 1.7-1.5 1.7-2.3 0-2.3 2-3c2-.9 1.5-1.3 2.2-3.1.6-1.8 2.5-1.4 1.8-3.3q-1-3-2.3-3.3-1.1-.4-1-2.6c0-1.5 1.8.1-.6-1.1-2.5-1.3-.4-2.6-3.3-2-3 .4-5.6 4.1-6.6 1.4s-1.8-4.3-.5-5.7S105 87 105 87l6-.3s1.8-.6 2.4-1.4 1-1.7 1-2.2.3-1-.2-2.2-1.6-2.6-1.4-3c.3-.6 1.4-2.3 3.7-2.5 2.2-.2 3.8-2.3 3.8-2.3s1.1.2 2.4 0c1.3-.1 1.8-2.6 1-2.9s-3-4-3-4-2.8-5-.2-5 3.6.5 5.3 0c1.8-.5.6 1 1.4-.8.8-1.7-.7-3.3-.7-3.3s.7-1 .4-2.6c-.4-1.6 1.6-3.1-2-3.3-3.5-.2-4-1.6-3.7-2.7s-1.4-.9-1.4-.9.6-1.3-3-1c-3.5.3-3.8 0-3.9-2.4 0-2.4 2.5-7 2.5-7l3.6-.1c1.3-.2.7-.6 2.7-1.3 2-.6 3-1.4 4.4-1.1 1.3.2 6.5-1 6.5-1s1 2 2.6.4c1.8-1.6 3.7-.1 4.9.4 1.1.6 5 1.8 5.2 1.9s2-.5 1.9 2.4 3 2 3.2 3.3q.3 2 1.2 2.3c.7.2 3-.1 4.3 1l4.1 3.5s4.7 1.7 3.1 3c-1.6 1.2-3.6 2.1-2.4 3 1 1 .6 3.7.6 3.7s.4-1.3.4.9 2 2.6 1.5 3.3c-.5.9 2.5 1.7 2.5 1.7s3 1.1 2.8.3c-.4-.8.7-4.6-.8-5.4s-.4-1.9-.7-3.8c-.3-2-.7-3-.2-3s6 3.9 8 6.6a22 22 0 0 1 3.4 9.7c.1 2 .5 1.5 1.6 4.8 1.1 3.4 3.2 8.8 2.3 8.2 0 .5-.4 6.2-.3 7.8s0 8.4 0 11.5.8 5.6.3 6.8-1.3 3.3-1.4 4.7c-.2 1.5-.6 0-1.2 2.3-.6 2.4-2.6 3.4-2.8 6-.2 2.8.2 4 .2 4.7s-2.3 2-3.1 3c-.8 1.2-2.6 1-3.6 2s-3 .4-2.6 2 .5 2.8-.6 3.7-.8-.1-2 1.6c-1 1.8-3.7 2.3-3.7 2.3-.8.8-2.4-.5-2.4-.5s-1.5-1.8-1.4-3.2-1.6-2.3-1.6-2.8.9-1.4-.8-3.3c-1.6-2 1.3-3-1.3-2.7s0-2.3-2.7.5-5.3 1.4-5.8 2.2-.8 2.2-.5 3 .3 1.8.3 1.8"/><path fill="#404040" d="M143 133.5c-1.9.7-4.6 2.6-3.2 2.9 1.5.3 1.4 1.6 1.4 1.6s-1.8 1.3 0 2c1.7.9 3.8.7 3.8 2.2 0 1.4-1.6 1.9-1.5 3.3s1 3.5.4 4.5-.4.3-1.7 1.7c-1.2 1.5-1.6.7-3.8 2.1-2.3 1.5-1.5 5-3.4 3.4-2-1.6 0-2.9-2-2.9h-3.2c-1 0-3 .5-2.1-1.6.8-2 3.3-2.8-.2-4.5-3.5-1.5-3-5.2-1.6-5.2 1.5 0 5.2.5 5.5-.8s1.3-2.8 1.5-4 2.7-4.2-.8-4.6c-3.6-.5-3.4-.5-3.6 1.4-.2 2-3 2.4-3.7 3.2s.6 0-1 2.1c-1.6 2-.8 1.6-2 3.5-1.4 2-.4 2 0 4.3.2 2.4 2.3 4.3.4 5.2-2 .8-1 .8-2.8 1-1.7 0-5.8.2-5.6-1 .2-1.3-1.3-5.5-.8-5.5s3.2 2.1 3.3.2c.2-2 .4-.7-1.2-2.5s1-2-1.7-1.8-6.6-.8-6.6-.8l-.8-1.2c-.3-.4-2 0-1.8-1.5.2-1.6.8-2.3-1.1-3-2-.6-2.1-.9-3.6-1.4-1.4-.4-2.5-2.5-2.5-3.3s-1.5-3.5 0-3.7l3.7-.6q2 0 2.4-1.8l.2-1.3c.1-1.4-.3-4.5-.3-4.5s-1-2.2-.5-3.3l2-3.9s3.6 2.6 4.3 2.3 1.8-1.5 1.8-2.3-.7-2.7 1.3-3.5 2-1.6 2.6-3.4 3.2-1.7 2.5-3.6c-.6-2-2-3.7-2.9-3.9s-1.4-1.4-1.4-2.8c0-1.5 2.4-.2 0-1.5s-1-2.7-4-2.2c-2.8.4-4.6 4.8-5.6 2s-2.5-3.4-1.1-4.8a9 9 0 0 1 2.8-2.2h6.3s1.8-1 2.3-1.7c.6-.8 1.6-1.8 1.6-2.3s.3-1-.2-2.2-1.6-2.6-1.4-3c.3-.5 1-2.3 3.3-2.4s4-2.3 4-2.3 1.2.2 2.5 0c1.3-.1 2.8-2.3 2-2.5-.9-.3-3.5-3.4-3.5-3.4s-2.7-5.9-.1-5.9c2.5 0 4.2.5 6 0s.9 1.3 1.7-.5c.8-1.7-2-4.1-2-4.1s.4-1.4 0-3c-.3-1.6 1.5-3.7-2-3.9-3.6-.1-4.9-1.6-4.7-2.7s0-1.5 0-1.5.5-1-3.1-.8c-3.5.3-4 1.5-4.1-1-.1-2.3 2.5-6.3 2.5-6.3h2.8c1.3-.2 0-.2 2-.8s3.1-1.2 4.4-1c1.3.3 7.1-1.4 7.1-1.4s1.3 2.4 2.6 0 1.8-.6 3 0c1 .6 5.3 1.3 5.4 1.5.2 0 3-.6 2.8 2.3s2.4 2.4 2.6 3.7q0 1.9 1 2c.6.2 3.2-.1 4.5 1s4.4 3.2 4.4 3.2 5.5 1.8 3.9 3c-1.7 1.3-4.1 1.2-3 2.1 1.2 1 1.5 4 1.5 4s.2-1 .2 1.1c0 2.3 2 3.4 1.6 4.2-.5.8 3.2 2.9 3.2 2.9s3.4.5 3.1-.3 2-3.7 0-4.2-.8-1.9-1.1-3.8c-.3-2-.7-3-.2-3a22 22 0 0 1 6.3 5.4c2.1 2.7 2.4 5.9 2.6 8s.3.8 1.5 4.1l2.4 7.1.8 7.6c.2 1.6-1 9-1 12s1.5 4.6 1 6c-.5 1.2-1.1 3.4-1.3 4.9-.1 1.4.2.5-.5 2.8-.6 2.4-3 5-3.2 7.7s.2 4 .2 4.6c0 .7-2.3 2-3.1 3-.8 1.2-2.6 1-3.6 2s-3.8.5-3.5 2c.3 1.7 1.5 2.8.3 3.8s-.8-.2-2 1.5c-1 1.8-3.7 2.3-3.7 2.3-.8.8-2.4-.5-2.4-.5s-.2-1.8 0-3.2-1.1-2.7-1.1-3.2 1.3-1-.3-2.9.3-3-2.3-2.7-1.3-2.2-4 .5-5.4 1.4-5.9 2.2-.8 2.3-.4 3c.3.9-1.5 1-1.5 1"/></g><g opacity=".2"><path fill="#fff" d="M121.6 114c.7-.6-1.4-3-1.4-3s3-3.4 1.5-4.4c-1.5-.9.8-5 .8-5s-1-.8.4-1c1.3-.3 3.2-2 4-3.3s-3.3-4.9-3.3-4.9 1.8 1.3 1.8-1.4.3-.6 1.5-2c1.3-1.4 3.3-1 3.3-1l2.7-2.1s3.2-2 5-2c1.8-.1-2 2.1 2.4 1 4.4-1 .2-1.5 1.6-.4s2.2 1 3.4-.6 1-.4 1-.4 0-2.1-.8-2c-.7.2-2.2-2.7-3-4.5s-1.4-12.9-.1-12.7l5.7.4s1.3-1 2-.7c.8.2 6.3.3 6.3.3s1.3-.5 2.3.5 6 1.1 5.6 3.2 2.9 1.6 2.9 1.6l-2 6c-.3 1.2 4.4 3.1 3 5.1-1.3 2 3 6 3.4 7.8.4 1.6-5.8 7.4-7.6 7.4-1.8-.1-2.4 1.2-3 5.2s-1.5-1.6-2 0 5.1 6.3 3.4 6.9c-1.8.5-3.3 8.6-4.7 10.8s-8.4 10.8-13 8.4c-4.7-2.4-5.7-1.7-5.7-1.7s-2.5-.6-3-1c-.6-.2-.6.4-4.1.2-3.6-.2-6.5 1.6-7.2 1.7s-5 5.6-8 4.7c-3-1-4.7-4.2-6-5.5-1.1-1.3 1.7-5.7 2-6.7s3-4.6 3-4.6-2.3-1.9 0-2.3 4 3.3 5.9 1.8z"/><path fill="#b6b6b6" d="M123.4 115c.6-.6-.9-3.6-.9-3.6s2.7-2.6 1.2-3.5c-1.5-1-.5-5.2-.5-5.2s-1-.7.3-1a8 8 0 0 0 4-3.2c.7-1.3-.2-3.8-.2-3.8s.3 1.2.3-1.5-.5-.8.7-2.2 2.5-1.7 2.5-1.7l4-1.3s1.9-2 3.7-2.1c1.9 0-1.4 2.2 3 1.2s1.7-1.7 3.1-.6 2.3 1 3.5-.7 1-1.2 1-1.2-1.5-2.5-2.2-2.4-2.2-2.8-3-4.6-1.3-10.5 0-10.3l5.2.4s.7-1 1.4-.9c.7.3 6 .9 6 .9s1.2-.4 2.3.6 5 1.2 4.5 3.3 1.2 2.8 1.2 2.8 0 3.1-.4 4.3c-.4 1.3 2.6 3.1 1.2 5.1-1.3 2 1.3 6 1.7 7.8.3 1.6-4.3 5.6-6 5.5-1.9 0-2.4 0-3 4s-.9.6-1.4 2.2 4.6 5.8 2.8 6.4c-1.8.5-3.5 6.6-5 8.8-1.3 2.2-4.8 9.3-9.5 7-4.6-2.4-5.5-1.7-5.5-1.7s-2.5-.7-3.1-1-.6.4-4.1.2c-3.6-.3-6.3 1.6-7 1.7s-3.7 4.2-6.7 3.2-5-1-6.2-2.2.8-4.5 1.1-5.5 3-4.7 3-4.7-1.2-1 1-1.4 4.2 2.2 6 .8"/><path fill="#6d6d6d" d="M125 116c.8-.6-.2-4.2-.2-4.2s2.4-1.7.9-2.6-1.9-5.2-1.9-5.2-1-.8.4-1a8 8 0 0 0 4-3.3c.7-1.3 2.8-2.7 2.8-2.7s-1.2 1.1-1.2-1.6c0-2.6-1.3-1-.1-2.4l1.8-2.5 5-.4s.8-2 2.6-2.2c1.9 0-.9 2.3 3.6 1.2s3.2-1.7 4.6-.6c1.5 1.1 2.3.8 3.5-.8s.9-2.2.9-2.2-2.7-2.8-3.5-2.6-2-2.9-2.8-4.7-1.4-8.1-.1-8l4.8.4s0-1.2.7-1l5.6 1.4s1.3-.4 2.3.6 4 1.4 3.6 3.6c-.5 2-.5 4-.5 4s1.4 1.4 1 2.6.8 3.1-.5 5.1-.5 6 0 7.7c.3 1.7-2.8 3.9-4.6 3.8-1.8 0-2.3-1.4-3 2.6-.5 4 0 2.8-.6 4.4s4 5.4 2.2 6-3.8 4.5-5.2 6.7-1.4 7.9-6 5.5c-4.7-2.3-5.6-1.6-5.6-1.6s-2.5-.7-3-1-.6.4-4.2.2c-3.5-.2-6 1.7-6.8 1.8-.6 0-2.3 2.7-5.3 1.8s-5.2 2.3-6.5 1 0-3.4.3-4.4c.4-1 3-4.7 3-4.7s-.2-.1 2-.5 4.3 1.1 6.1-.3"/><path fill="#404040" d="M126.2 116.5c.6-.5 0-4.4 0-4.4s2.2-1.1.7-2c-1.5-1-2.7-5.4-2.7-5.4s-1-.7.3-1c1.4-.2 3.3-1.9 4-3.2s4.8-2 4.8-2-2.1 1-2.2-1.6c0-2.7-1.8-1.3-.6-2.6s1.3-3 1.3-3l5.8.2s0-2.2 2-2.3c1.7 0-.6 2.4 3.8 1.3s4.2-1.8 5.7-.7c1.4 1.1 2.2.7 3.4-1 1.2-1.5.8-2.5.8-2.5s-3.5-3-4.2-2.9-2-3-2.8-4.7-1.4-6.7-.1-6.6l4.5.4s-.4-1.3.3-1l5.4 1.6s1.3-.2 2.3.8 3.4 1.4 3 3.6c-.5 2-1.7 4.8-1.7 4.8s2.4.3 2 1.5c-.4 1.3-.3 3-1.6 5.1s-1.5 6-1.1 7.7-1.8 2.7-3.6 2.6c-1.8 0-2.2-2.2-2.9 1.9s.3 4-.2 5.7c-.6 1.6 3.5 5.1 1.8 5.7-1.8.5-4 3.2-5.4 5.5s.9 7-3.8 4.6-5.6-1.7-5.6-1.7-2.5-.6-3-1c-.6-.2-.6.4-4.2.2-3.5-.2-6 1.8-6.7 1.8-.7.1-1.4 1.9-4.4 1-3-1-5.5 4.2-6.7 3s-.6-2.7-.2-3.7 2.9-4.7 2.9-4.7.5.4 2.7 0 4.3.5 6.1-1"/></g></g></g><circle cx="100" cy="100" r="94" fill="url(#hp-moon-c)"/></g>`; diff --git a/src/moon-gate.ts b/src/moon-gate.ts new file mode 100644 index 00000000..6c96ff26 --- /dev/null +++ b/src/moon-gate.ts @@ -0,0 +1,50 @@ +/** + * 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. + * + * 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 + * 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'; + +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; +/** Earliest next attempt; 0 before the first, Infinity after a foreign build. */ +let nextAttempt = 0; + +/** + * 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. + */ +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) { + const retry = nextAttempt > 0; + nextAttempt = Date.now() + 30_000; + (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); + } + return runtime?.renderMoon(host, settings, state.phase) ?? nothing; +} diff --git a/src/moon-runtime.ts b/src/moon-runtime.ts new file mode 100644 index 00000000..ff5733b7 --- /dev/null +++ b/src/moon-runtime.ts @@ -0,0 +1,122 @@ +/** + * 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. + * + * 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. + */ +import { html, nothing, type TemplateResult } from 'lit'; +import { MOON_ART } from './moon-art.generated'; +import { + MOON_SHARP_FROM, moonFingerprint, moonPhasePath, moonView, type MoonView, +} from './moon'; +import type { DayCyclePhase } from './sun'; + +export const MOON_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__'; + +/** The card that shows the moon: the View card or `houseplan-space-card`. */ +export interface MoonHost { + readonly isConnected: boolean; + readonly updateComplete: Promise<unknown>; + readonly hass?: { config?: unknown }; + requestUpdate(): void; +} + +/** C3: the environment ticker period; the moon rises at most 0.25°/min. */ +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. + * C4: opacity only, 2 s on the background curve; reduced motion — instant. + * C8: no CSS filter and no will-change on the element. + */ +const MOON_CSS = '.hp-day-cycle-env{container-type:size}' + + '.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;' + + 'transition:opacity 2000ms cubic-bezier(.22,.61,.36,1)}' // RAY_FADE_MS, pinned by the test + + '.hp-moon.on{opacity:1}' + + '@media (prefers-reduced-motion:reduce){.hp-moon{transition:none}}'; + +interface MoonWatch { + settings: unknown; + phase: DayCyclePhase; + /** Fingerprint of what is on screen (or was last asked for). */ + key: string; + /** Set by every render that includes the moon; cleared when a tick asks for one. */ + seen: boolean; + timer: ReturnType<typeof setInterval>; +} + +const watches = new WeakMap<MoonHost, MoonWatch>(); + +function forget(host: MoonHost, watch: MoonWatch): void { + clearInterval(watch.timer); + if (watches.get(host) === watch) watches.delete(host); +} + +/** + * 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. + */ +export function moonTick(host: MoonHost, now: Date = new Date()): void { + const watch = watches.get(host); + if (!watch) return; + if (!host.isConnected) { forget(host, watch); return; } + if (globalThis.document?.visibilityState === 'hidden') return; + const key = moonFingerprint(moonView(watch.settings, watch.phase, host.hass?.config, now)); + if (key === watch.key) return; + watch.key = key; + watch.seen = false; + host.requestUpdate(); + void host.updateComplete.then(() => { if (!watch.seen) forget(host, watch); }); +} + +function remember(host: MoonHost, settings: unknown, phase: DayCyclePhase, view: MoonView): void { + const key = moonFingerprint(view); + const watch = watches.get(host); + if (watch) { + Object.assign(watch, { settings, phase, key, seen: true }); + return; + } + watches.set(host, { + settings, phase, key, seen: true, + timer: setInterval(() => moonTick(host), MOON_TICK_MS), + }); +} + +/** + * The element stays in the environment 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. + */ +export function renderMoon( + host: MoonHost, settings: unknown, phase: DayCyclePhase, now: Date = new Date(), +): TemplateResult { + const view = moonView(settings, phase, host.hass?.config, now); + remember(host, settings, phase, view); + return html`<style>${MOON_CSS}</style><svg class="hp-moon${view.visible ? ' on' : ''}" + viewBox="0 0 512 512" aria-hidden="true" focusable="false" + data-moon-k=${view.k.toFixed(2)} data-moon-visible=${view.visible ? 'true' : 'false'}> + <defs> + <g id="hp-moon-art">${MOON_ART}</g> + <filter id="hp-moon-soft" x="-5%" y="-5%" width="110%" height="110%"> + <feGaussianBlur stdDeviation="5"></feGaussianBlur> + </filter> + <mask id="hp-moon-phase" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"> + <path d=${moonPhasePath(view.k)} fill="#fff" + filter=${view.k >= MOON_SHARP_FROM ? nothing : 'url(#hp-moon-soft)'}></path> + </mask> + </defs> + <use href="#hp-moon-art" opacity="0.08"></use> + <use href="#hp-moon-art" mask="url(#hp-moon-phase)"></use> + </svg>`; +} diff --git a/src/moon.ts b/src/moon.ts new file mode 100644 index 00000000..de7be0e9 --- /dev/null +++ b/src/moon.ts @@ -0,0 +1,156 @@ +/** + * 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. + * + * 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 } 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 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`. + */ +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 }; +} + +/** 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`; +} diff --git a/src/space-card.ts b/src/space-card.ts index b775885d..3e17cbba 100644 --- a/src/space-card.ts +++ b/src/space-card.ts @@ -892,6 +892,7 @@ class HouseplanSpaceCard extends LitElement { showTemperature: this._config.show_temperature !== false, showSignal: this._config.show_signal !== false, inert: this._continuity.overlayBlocksInteraction, + moonHost: this, }); if (!stage) { return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId })); diff --git a/src/space-render.ts b/src/space-render.ts index 948371c4..25d4c712 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -25,6 +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 { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render'; import type { DevItem, OpeningCfg, ServerConfig } from './types'; import { floorMinusBodies, physicalBodyParts, polyclipPathD } from './physical-geometry'; @@ -219,6 +220,8 @@ export interface StaticRenderOpts { glowRuntime?: StaticGlowRuntime; /** Deterministic clock injection for unit/smoke fixtures; production omits it. */ dayCycleNow?: Date | number; + /** #661: the card that shows the moon (re-rendered when its runtime arrives or it moves). */ + moonHost?: MoonHost; virtualLights?: VirtualLightSnapshot | null; /** * Resolve a stored content url to what the DOM may actually request — the @@ -927,7 +930,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null { <div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}" ?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)} + ${renderDayCycleEnvironment(dayCycle, 1, moonLayer(o.moonHost, o.cfg?.settings, dayCycle))} ${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"> diff --git a/src/types.ts b/src/types.ts index ac491430..bf38d6d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -305,6 +305,8 @@ export interface ServerConfig { show_room_tooltip?: boolean; /** #649: the whole installation views plans in 2.5D; absent or false keeps Flat. */ volumetric_view?: boolean; + /** #661: the moon on the "Follow the Sun" background; only `true` switches it on. */ + moon?: boolean; north_deg?: number; bg_mode?: 'static' | 'daynight'; sun_rays?: boolean; diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index 3da097e0..65660833 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -191,7 +191,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph', 'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js', 'houseplan-assets/de-HASH.js', 'houseplan-assets/iso-scene-render-HASH.js', 'houseplan-assets/furniture-plan-art.generated-HASH.js', - 'houseplan-assets/pdf-export-HASH.js', + 'houseplan-assets/pdf-export-HASH.js', 'houseplan-assets/moon-runtime-HASH.js', ], }, 'shared.js': { @@ -232,6 +232,11 @@ test('bundle manifest separates static initial graph from dynamic editor graph', code: 'PDF export runtime', isEntry: false, imports: ['shared.js'], dynamicImports: [], modules: { '/repo/src/pdf/pdf-export.ts': {} }, }, + 'houseplan-assets/moon-runtime-HASH.js': { + type: 'chunk', fileName: 'houseplan-assets/moon-runtime-HASH.js', + code: 'moon runtime', isEntry: false, imports: ['shared.js'], dynamicImports: [], + modules: { '/repo/src/moon-runtime.ts': {}, '/repo/src/moon-art.generated.ts': {} }, + }, }, 'fingerprint'); assert.equal(manifest.entry, 'houseplan-card.js'); assert.equal(manifest.panelEntry, 'houseplan-panel.js'); @@ -252,6 +257,9 @@ test('bundle manifest separates static initial graph from dynamic editor graph', assert.deepEqual(manifest.lazyIsometricFiles, ['houseplan-assets/iso-scene-render-HASH.js']); assert.deepEqual(manifest.lazyFurnitureArtFiles, ['houseplan-assets/furniture-plan-art.generated-HASH.js']); assert.deepEqual(manifest.lazyPdfFiles, ['houseplan-assets/pdf-export-HASH.js']); + // #661 AC8: the moon (math, art, template) is its own lazy graph, never initial. + assert.deepEqual(manifest.lazyMoonFiles, ['houseplan-assets/moon-runtime-HASH.js']); + assert.equal(manifest.initialViewFiles.includes('houseplan-assets/moon-runtime-HASH.js'), false); assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js', 'houseplan-assets/furniture-plan-art.generated-HASH.js']); // #627: found by module, not as a root of the initial graph; own graph. assert.deepEqual(manifest.lazyNamespaceLocaleFiles, NAMESPACE_LOCALE_CHUNKS.map(namespaceLocaleChunkPath).sort()); @@ -263,6 +271,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph', 'houseplan-assets/furniture-plan-art.generated-HASH.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js', 'houseplan-assets/iso-scene-render-HASH.js', + 'houseplan-assets/moon-runtime-HASH.js', 'houseplan-assets/pdf-export-HASH.js', ]); // Этот тест про РАЗДЕЛЕНИЕ графов, а не про их размеры: потолки ленивых @@ -291,6 +300,10 @@ test('bundle manifest separates static initial graph from dynamic editor graph', assert.throws(() => assertBundleBudget({ ...manifest, lazyNamespaceLocaleFiles: manifest.lazyNamespaceLocaleFiles.slice(1), }, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 8 files, expected 9/); + // #661 AC8: a moon pulled into the first frame (a static import leaves no + // lazy moon graph — the builder subtracts initial) is refused by name. + assert.throws(() => assertBundleBudget({ ...manifest, lazyMoonFiles: [] }, 1_000_000, undefined, ...lazyCeilings), + /bundle has no lazy moon graph/); }); test('#486 Rollup names both stable entries explicitly', async () => { @@ -518,7 +531,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () => + 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url);' + 'new URL("__HOUSEPLAN_FR_RETRY_ASSET__", import.meta.url);' + 'new URL("__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__", import.meta.url);' - + 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url)', modules: {}, + + 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url);' + + 'new URL("__HOUSEPLAN_MOON_RETRY_ASSET__", import.meta.url)', modules: {}, }, // #627: the lazy namespace chunk that owns the nine second-attempt tokens. 'houseplan-assets/backdrop-pick-HASH.js': { @@ -554,6 +568,10 @@ test('retry URL points at the content-hashed runtime chunk after naming', () => type: 'chunk', fileName: 'houseplan-assets/pdf-export-HASH.js', code: '', modules: { '/repo/src/pdf/pdf-export.ts': {} }, }, + 'houseplan-assets/moon-runtime-HASH.js': { + type: 'chunk', fileName: 'houseplan-assets/moon-runtime-HASH.js', code: '', + modules: { '/repo/src/moon-runtime.ts': {} }, + }, }; plugin.generateBundle({}, bundle); assert.equal( @@ -564,7 +582,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () => + 'new URL("./de-HASH.js", import.meta.url);' + 'new URL("./fr-HASH.js", import.meta.url);' + 'new URL("./furniture-plan-art.generated-HASH.js", import.meta.url);' - + 'new URL("./pdf-export-HASH.js", import.meta.url)', + + 'new URL("./pdf-export-HASH.js", import.meta.url);' + + 'new URL("./moon-runtime-HASH.js", import.meta.url)', ); assert.equal( bundle['houseplan-assets/backdrop-pick-HASH.js'].code, @@ -577,7 +596,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi const bundle = { 'houseplan-assets/houseplan-card.js': { type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js', modules: {}, - code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF'] + code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON'] .map((name) => `"__HOUSEPLAN_${name}_RETRY_ASSET__"`).join(';'), }, ...Object.fromEntries([ @@ -585,7 +604,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi ['houseplan-onboarding-runtime', '/src/houseplan-onboarding-runtime.ts'], ['iso-scene-render', '/src/iso-scene-render.ts'], ['de', '/src/i18n/de.ts'], ['fr', '/src/i18n/fr.ts'], ['furniture-plan-art.generated', '/src/furniture-plan-art.generated.ts'], - ['pdf-export', '/src/pdf/pdf-export.ts'], + ['pdf-export', '/src/pdf/pdf-export.ts'], ['moon-runtime', '/src/moon-runtime.ts'], ].map(([name, module]) => [`houseplan-assets/${name}-HASH.js`, { type: 'chunk', fileName: `houseplan-assets/${name}-HASH.js`, code: '', modules: { [`/repo${module}`]: {} }, }])), @@ -979,6 +998,7 @@ const runBudgetCli = (initialViewGzipBytes) => { writeFileSync(join(dir, 'dist/isometric.js'), 'lazy isometric runtime'); writeFileSync(join(dir, 'dist/furniture-art.js'), 'lazy furniture artwork'); writeFileSync(join(dir, 'dist/pdf.js'), 'lazy pdf writer'); + writeFileSync(join(dir, 'dist/moon.js'), 'lazy moon runtime'); writeFileSync(join(dir, 'dist/houseplan-assets.json'), JSON.stringify({ schema: 1, fingerprint: 'f'.repeat(64), @@ -1008,6 +1028,8 @@ const runBudgetCli = (initialViewGzipBytes) => { lazyFurnitureArtGzipBytes: LAZY_FURNITURE_ART_GZIP_CEILING - 1_000, lazyPdfFiles: ['pdf.js'], lazyPdfGzipBytes: 100, + lazyMoonFiles: ['moon.js'], + lazyMoonGzipBytes: 100, lazyOnboardingFiles: ['onboarding.js'], lazyOnboardingGzipBytes: LAZY_ONBOARDING_GZIP_CEILING - 1_000, lazyNamespaceLocaleFiles: NAMESPACE_LOCALE_CHUNKS diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index b9c30e1b..50558b49 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity assert.ok(scenario); const fixture = prepareGoldenFixture(scenario); const space = fixture.config.spaces.find((item) => item.id === scenario.space); - assert.equal(GOLDEN_MATRIX_VERSION, 67); + assert.equal(GOLDEN_MATRIX_VERSION, 68); assert.equal(space.settings.sun_rays, true); assert.equal(scenario.northDeg, 90, 'the sign-sensitive golden must keep a non-zero north direction'); @@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => { const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id); - assert.equal(GOLDEN_MATRIX_VERSION, 67); + assert.equal(GOLDEN_MATRIX_VERSION, 68); assert.deepEqual(ids, [ 'isometric-stage6-light-lightfloor', 'isometric-stage6-light-darkfloor', @@ -1226,3 +1226,21 @@ test('issue 43 support dialog has the six reviewed responsive states', () => { assert.equal(support.filter((scenario) => scenario.viewport.width >= 900).length, 3); for (const scenario of support) assert.equal(scenario.capture, 'page', scenario.id); }); + +test('#661 AC7 moon scenes: fixed home and clock, moon on, the old day-cycle frames untouched', () => { + const byId = (id) => GOLDEN_SCENARIOS.find((scenario) => scenario.id === id); + const gibbous = byId('day-cycle-night-moon-gibbous-dark'); + const crescent = byId('day-cycle-dusk-moon-crescent-south-dark'); + assert.deepEqual(gibbous.moon, { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' }); + assert.deepEqual(crescent.moon, { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' }); + for (const scenario of [gibbous, crescent]) { + assert.equal(scenario.capture, 'stage'); + assert.equal(scenario.bgMode, 'daynight'); + assert.equal(prepareGoldenFixture(scenario).config.settings.moon, true); + } + for (const id of ['day-cycle-dawn-dark', 'day-cycle-day-dark', 'day-cycle-dusk-dark', 'day-cycle-night-dark']) { + const scenario = byId(id); + assert.equal(scenario.moon, undefined, id); + assert.equal(prepareGoldenFixture(scenario).config.settings?.moon, undefined, id); + } +}); diff --git a/test/golden-wsl-artifact.test.mjs b/test/golden-wsl-artifact.test.mjs index 5f2e408b..e0081375 100644 --- a/test/golden-wsl-artifact.test.mjs +++ b/test/golden-wsl-artifact.test.mjs @@ -42,15 +42,25 @@ const toolchain = () => { }; }; +/** Matrix scenarios whose first capture still awaits acceptance (declared as new). */ +const PENDING_NEW = Object.freeze(GOLDEN_SCENARIOS.map((scenario) => scenario.id) + .filter((id) => !existsSync(resolve(BASELINES, `${id}.png`)))); +const ACCEPTED = GOLDEN_SCENARIOS.map((scenario) => scenario.id).filter((id) => !PENDING_NEW.includes(id)); + function artifact() { const dir = mkdtempSync(resolve(tmpdir(), 'hp-golden-wsl-')); const actualRoot = resolve(dir, 'actual'); mkdirSync(actualRoot, { recursive: true }); const index = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8')); const results = GOLDEN_SCENARIOS.map((scenario) => { - const baseline = resolve(BASELINES, `${scenario.id}.png`); - assert.equal(existsSync(baseline), true, `test fixture needs reviewed baseline ${scenario.id}`); const actual = resolve(actualRoot, `${scenario.id}.png`); + // #661: a scenario added to the matrix has no reviewed baseline until its + // first Linux capture is accepted — exactly what a real run reports then. + if (PENDING_NEW.includes(scenario.id)) { + copyFileSync(resolve(BASELINES, `${ACCEPTED[0]}.png`), actual); + return { id: scenario.id, status: 'missing-baseline', actualSha256: digest(readFileSync(actual)) }; + } + const baseline = resolve(BASELINES, `${scenario.id}.png`); copyFileSync(baseline, actual); const sha = digest(readFileSync(actual)); return { id: scenario.id, status: 'passed', actualSha256: sha, baselineSha256: sha }; @@ -72,7 +82,7 @@ function artifact() { return dir; } -const intent = Object.freeze({ expectChange: [], expectNew: [], noWitnesses: false, reason: '' }); +const intent = Object.freeze({ expectChange: [], expectNew: PENDING_NEW, noWitnesses: false, reason: '' }); test('#641: repository and WSL/ext4 preconditions fail closed', () => { assert.equal(repositoryRefusal(source), null); diff --git a/test/moon-settings.test.mjs b/test/moon-settings.test.mjs new file mode 100644 index 00000000..1cd44e88 --- /dev/null +++ b/test/moon-settings.test.mjs @@ -0,0 +1,53 @@ +// #661 AC10: the moon switch in General settings — «Sun and Moon» section, +// reset to on, save writes `true` or removes the key; its strings exist in all +// four dictionaries. The dialog is a lazy Lit module (its layout is read as a +// source contract); reading and writing the key are executed. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { moonDraftOf, writeMoonSetting } from '../test-build/editors/general-form-state.js'; + +const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); +const json = (path) => JSON.parse(read(path)); + +test('#661 AC10: the switch sits in the «Sun and Moon» card under «Sunlight through windows»', () => { + const dialog = read('src/editors/general-settings-dialog.ts'); + const sun = dialog.slice(dialog.indexOf("id: 'sun',"), dialog.indexOf("id: 'data',")); + assert.match(sun, /title: t\('gs\.sun_group'\)/); + const rays = sun.indexOf("id: 'gs-sun-rays'"); + const moon = sun.indexOf("id: 'gs-moon'"); + assert.ok(rays > 0 && moon > rays, 'moon row follows the sun-rays row'); + const row = sun.slice(moon, sun.indexOf('})}', moon)); + assert.match(row, /icon: 'mdi:moon-waning-crescent'/); + assert.match(row, /title: st\('gs\.moon'\), caption: st\('gs\.moon_hint'\)/); + assert.match(row, /checked: d\.moon, onChange: \(v\) => set\(\{ moon: v \}\)/); + const reset = dialog.slice(dialog.indexOf("${t('gs.reset')}") - 600, dialog.indexOf("${t('gs.reset')}")); + assert.match(reset, /moon: true/, 'reset to defaults switches the moon on (new installations)'); +}); + +test('#661 AC10: opening reads only an explicit true; saving writes true or removes the key', () => { + assert.equal(moonDraftOf({ moon: true }), true); + for (const settings of [undefined, null, {}, { moon: false }, { moon: 'true' }, { moon: 1 }]) { + assert.equal(moonDraftOf(settings), false, JSON.stringify(settings)); + } + const on = { bg_mode: 'daynight' }; + writeMoonSetting(on, true); + assert.deepEqual(on, { bg_mode: 'daynight', moon: true }); + const off = { bg_mode: 'daynight', moon: true }; + writeMoonSetting(off, false); + assert.deepEqual(off, { bg_mode: 'daynight' }); + assert.equal('moon' in off, false, 'false is stored as absence'); +}); + +test('#661 AC10: strings in all four dictionaries, section renamed', () => { + const group = { en: 'Sun and Moon', ru: 'Солнце и Луна', de: 'Sonne und Mond', fr: 'Soleil et Lune' }; + for (const lang of ['en', 'ru', 'de', 'fr']) { + assert.equal(json(`src/i18n/${lang}.json`)['gs.sun_group'], group[lang], lang); + const settings = json(`src/i18n/settings/${lang}.json`); + for (const key of ['gs.moon', 'gs.moon_hint']) { + assert.ok(typeof settings[key] === 'string' && settings[key].trim(), `${lang} ${key}`); + } + } + assert.equal(json('src/i18n/settings/ru.json')['gs.moon'], 'Луна на плане в сумерках и ночью'); + assert.equal(json('src/i18n/settings/en.json')['gs.moon'], 'Moon over the plan at dusk and night'); +}); diff --git a/test/moon.test.mjs b/test/moon.test.mjs new file mode 100644 index 00000000..4318fc53 --- /dev/null +++ b/test/moon.test.mjs @@ -0,0 +1,253 @@ +// #661: the moon over the "Follow the Sun" background — astronomy (AC1), +// visibility rules (AC2), phase mask (AC3), fingerprint and ticker (AC4) and +// the designer pack gate. Pure: test-build modules, no browser. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { nothing } from 'lit'; +import { + MOON_ELEVATION_MIN, MOON_MIN_ILLUMINATION, MOON_R, MOON_SHARP_FROM, + moonFingerprint, moonIllumination, moonPhasePath, moonPosition, moonShownAt, moonView, +} from '../test-build/moon.js'; +import { MOON_TICK_MS, moonTick, renderMoon } from '../test-build/moon-runtime.js'; +import { moonLayer } from '../test-build/moon-gate.js'; +import { RAY_ELEVATION_MIN, RAY_FADE_MS, bgModeOf, resolveDayCycle } from '../test-build/sun.js'; +import { renderDayCycleEnvironment } from '../test-build/day-cycle-render.js'; +import { moonArtFromSvg, validateMoonPack } from '../scripts/generate-moon-assets.mjs'; + +const MOSCOW = { latitude: 55.75, longitude: 37.62 }; +const SYDNEY = { latitude: -33.87, longitude: 151.21 }; +const ON = { moon: true, bg_mode: 'daynight' }; + +/** + * AC1 reference: JPL Horizons, observer table, `CENTER='coord@399'` with + * `SITE_COORD='<lon>,<lat>,0'`, `QUANTITIES='4,10'` (apparent az/el and + * illuminated fraction), `APPARENT='AIRLESS'`, `TIME_TYPE=UT`, one row per + * epoch below. Moscow 55.75 N 37.62 E, Sydney 33.87 S 151.21 E, October 2026. + */ +const HORIZONS = [ + ['2026-10-01T18:00:00Z', MOSCOW, 9.052, 70.03], + ['2026-10-02T18:00:00Z', MOSCOW, 3.018, 59.01], + ['2026-10-03T18:00:00Z', MOSCOW, -3.347, 47.57], + ['2026-10-10T18:00:00Z', MOSCOW, -32.212, 0.176], + ['2026-10-18T18:00:00Z', MOSCOW, 4.050, 50.44], + ['2026-10-21T18:00:00Z', MOSCOW, 23.279, 78.16], + ['2026-10-25T18:00:00Z', MOSCOW, 37.066, 99.67], + ['2026-10-26T20:00:00Z', MOSCOW, 48.978, 99.27], + ['2026-10-31T18:00:00Z', MOSCOW, -0.096, 62.05], + ['2026-10-12T09:00:00Z', SYDNEY, 8.150, 3.00], + ['2026-10-14T09:00:00Z', SYDNEY, 30.692, 13.20], + ['2026-10-20T09:00:00Z', SYDNEY, 72.716, 66.31], +]; + +test('#661 AC1: altitude within 1.5° and illumination within 2.5 pp of JPL Horizons', () => { + for (const [utc, place, altitude, percent] of HORIZONS) { + const date = new Date(utc); + const position = moonPosition(date, place.latitude, place.longitude); + const { fraction } = moonIllumination(date); + assert.ok(Math.abs(position.altitude - altitude) <= 1.5, + `${utc} ${place.latitude}: altitude ${position.altitude.toFixed(3)} vs ${altitude}`); + assert.ok(Math.abs(fraction * 100 - percent) <= 2.5, + `${utc} ${place.latitude}: illumination ${(fraction * 100).toFixed(2)} vs ${percent}`); + assert.ok(position.azimuth >= 0 && position.azimuth < 360); + } +}); + +test('#661 AC1: waxing follows the lunation (first quarter grows, last quarter shrinks)', () => { + assert.equal(moonIllumination(new Date('2026-10-14T09:00:00Z')).waxing, true); + assert.equal(moonIllumination(new Date('2026-10-21T18:00:00Z')).waxing, true); + assert.equal(moonIllumination(new Date('2026-10-31T18:00:00Z')).waxing, false); +}); + +test('#661 C1: the moon keeps the rays’ horizon and a 3 % new-moon threshold', () => { + assert.equal(MOON_ELEVATION_MIN, RAY_ELEVATION_MIN); + assert.equal(MOON_ELEVATION_MIN, 3); + assert.equal(MOON_MIN_ILLUMINATION, 0.03); +}); + +test('#661 AC2: visibility thresholds on both sides', () => { + for (const phase of ['dawn', 'dusk', 'night']) { + assert.equal(moonShownAt(phase, 2.9, 0.5), false, `${phase} 2.9°`); + assert.equal(moonShownAt(phase, 3.0, 0.5), true, `${phase} 3.0°`); + assert.equal(moonShownAt(phase, 30, 0.029), false, `${phase} k 0.029`); + assert.equal(moonShownAt(phase, 30, 0.03), true, `${phase} k 0.03`); + } + assert.equal(moonShownAt('day', 45, 1), false); +}); + +test('#661 AC2: only an explicit true, finite home coordinates and an environment show the moon', () => { + const at = new Date('2026-10-21T18:00:00Z'); + assert.equal(moonView(ON, 'night', MOSCOW, at).visible, true); + for (const settings of [undefined, null, {}, { moon: false }, { moon: 'true' }, { moon: 1 }]) { + assert.equal(moonView(settings, 'night', MOSCOW, at).visible, false, JSON.stringify(settings)); + } + for (const config of [undefined, null, {}, { latitude: '55.75', longitude: 37.62 }, + { latitude: Number.NaN, longitude: 37.62 }, { latitude: 55.75, longitude: null }]) { + assert.equal(moonView(ON, 'night', config, at).visible, false, JSON.stringify(config)); + } + assert.equal(moonView(ON, 'day', MOSCOW, at).visible, false); + // bg_mode static — global or per space — leaves no environment, so no moon. + const host = fakeHost(MOSCOW); + const night = { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } }; + for (const [global, space] of [[{ ...ON, bg_mode: 'static' }, {}], [ON, { bg_mode: 'static' }]]) { + const state = bgModeOf(global, space) === 'daynight' ? resolveDayCycle({ states: night }, at) : null; + assert.equal(moonLayer(host, global, state), nothing); + } + assert.equal(moonLayer(host, { moon: false }, resolveDayCycle({ states: night }, at)), nothing); + assert.equal(moonLayer(undefined, ON, resolveDayCycle({ states: night }, at)), nothing); +}); + +/** Decode `A rx ry rot large sweep x y` of the terminator from the mask path. */ +function terminatorOf(path) { + const arcs = [...path.matchAll(/A([\d.]+) ([\d.]+) 0 0 ([01]) ([\d.]+) ([\d.]+)/g)] + .map((match) => match.slice(1).map(Number)); + return { outer: arcs[0], terminator: arcs[1] ?? null }; +} + +test('#661 AC3: the mask is the left half plus or minus the terminator half-ellipse', () => { + for (const k of [0.03, 0.25, 0.5, 0.75, 0.97]) { + const path = moonPhasePath(k); + const { outer, terminator } = terminatorOf(path); + // The lit half's outer arc runs along the box (r 256), counter-clockwise + // from the top: through the LEFT edge, never along the limb. + assert.deepEqual(outer, [256, 256, 0, 256, 512], `${k}: outer arc`); + const rx = MOON_R * Math.abs(2 * k - 1); + if (rx < 0.5) { + assert.equal(terminator, null, `${k}: a half moon has a straight terminator`); + assert.match(path, /L256 496L256 16L256 0Z$/); + continue; + } + const [trx, try_, sweep, x, y] = terminator; + assert.ok(Math.abs(trx - rx) < 0.01, `${k}: rx ${trx} vs ${rx}`); + assert.equal(try_, MOON_R); + assert.deepEqual([x, y], [256, 16], `${k}: the terminator ends at the north pole`); + // sweep 0 bulges right, into the dark side: the lit area grows past half. + const litShare = 0.5 + (sweep === 0 ? 1 : -1) * (trx / MOON_R) / 2; + assert.ok(Math.abs(litShare - k) < 1e-3, `${k}: lit share ${litShare}`); + } +}); + +test('#661 AC3 (owner 2026-09-29): one lit side in both hemispheres, waxing or waning', () => { + // Sydney 2026-10-14 09:00Z: waxing crescent, 14 % — lit on the LEFT, like Moscow. + const at = new Date('2026-10-14T09:00:00Z'); + const hosts = [fakeHost(SYDNEY), fakeHost(MOSCOW)]; + const sydney = renderMoon(hosts[0], ON, 'dusk', at); + const moscow = renderMoon(hosts[1], ON, 'night', at); + hosts.forEach(forgetHost); + const pathOf = (result) => result.values.find((value) => typeof value === 'string' && value.startsWith('M256 0A256')); + assert.equal(pathOf(sydney), pathOf(moscow)); + assert.equal(pathOf(sydney), moonPhasePath(0.14)); + // Waxing and waning at the same k draw the same state backwards. + const waxing = new Date('2026-10-21T18:00:00Z'); + const waning = new Date('2026-10-29T12:00:00Z'); + assert.equal(moonIllumination(waxing).waxing, true); + assert.equal(moonIllumination(waning).waxing, false); + assert.equal(moonPhasePath(0.78), moonPhasePath(Math.round(0.78 * 100) / 100)); +}); + +test('#661 C5: the feathered terminator stops only for a full disc', () => { + assert.equal(MOON_SHARP_FROM, 0.995); + const host = fakeHost(MOSCOW); + const filterOf = (at) => renderMoon(host, ON, 'night', new Date(at)).values + .find((value) => value === nothing || value === 'url(#hp-moon-soft)'); + assert.equal(filterOf('2026-10-21T18:00:00Z'), 'url(#hp-moon-soft)'); + forgetHost(host); +}); + +function fakeHost(config) { + return { + isConnected: true, + updates: 0, + updateComplete: Promise.resolve(true), + hass: { config }, + requestUpdate() { this.updates++; }, + }; +} + +function forgetHost(host) { + host.isConnected = false; + moonTick(host); +} + +test('#661 AC4: three ticks over an unchanged moon cost no render', () => { + const host = fakeHost(MOSCOW); + const start = new Date('2026-10-21T18:00:00Z'); + renderMoon(host, ON, 'night', start); + for (let tick = 1; tick <= 3; tick++) moonTick(host, new Date(start.getTime() + tick * MOON_TICK_MS)); + assert.equal(host.updates, 0); + assert.equal( + moonFingerprint(moonView(ON, 'night', MOSCOW, start)), + moonFingerprint(moonView(ON, 'night', MOSCOW, new Date(start.getTime() + 3 * MOON_TICK_MS))), + ); + forgetHost(host); +}); + +test('#661 AC4: the tick asks for exactly one render when the moon sets below 3°', async () => { + // Moscow 2026-10-21: 3.026° at 22:37Z, 2.902° at 22:38Z (the setting side). + const host = fakeHost(MOSCOW); + renderMoon(host, ON, 'night', new Date('2026-10-21T22:30:00Z')); + moonTick(host, new Date('2026-10-21T22:45:00Z')); + moonTick(host, new Date('2026-10-21T22:45:30Z')); + assert.equal(host.updates, 1); + // The host re-rendered without a moon (switched off): its ticker is dropped. + await host.updateComplete; + await Promise.resolve(); + moonTick(host, new Date('2026-10-22T12:00:00Z')); + assert.equal(host.updates, 1); +}); + +test('#661 C4: the element fades on the background curve for two seconds', () => { + const host = fakeHost(MOSCOW); + const result = renderMoon(host, ON, 'night', new Date('2026-10-21T18:00:00Z')); + forgetHost(host); + const css = result.values[0]; + assert.match(css, new RegExp(`transition:opacity ${RAY_FADE_MS}ms cubic-bezier\\(\\.22,\\.61,\\.36,1\\)`)); + assert.match(css, /prefers-reduced-motion:reduce\)\{\.hp-moon\{transition:none\}/); + assert.match(css, /--hp-moon-box:min\(200px,25cqmin\)/); + assert.match(css, /pointer-events:none/); + assert.doesNotMatch(css, /filter|will-change|z-index/); +}); + +test('#661 C6: the moon is the environment’s last child — above the phases, below the plan', () => { + const state = resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } } }); + const moon = { moon: 'marker' }; + const env = renderDayCycleEnvironment(state, 1, moon); + const at = env.values.indexOf(moon); + assert.ok(at > 0, 'the environment carries the moon'); + assert.equal(env.strings[at + 1].trim(), '</div>', 'the environment closes right after the moon'); + assert.match(env.strings[0], /class="hp-day-cycle-env"/); +}); + +test('#661 pack: generated art is fresh, and the gate refuses what the brief forbids', () => { + const run = spawnSync(process.execPath, ['scripts/generate-moon-assets.mjs', '--check'], { encoding: 'utf8' }); + assert.equal(run.status, 0, run.stderr); + const pack = JSON.parse(readFileSync(new URL('../assets/moon/houseplan-1.0.0/pack.json', import.meta.url), 'utf8')); + assert.doesNotThrow(() => validateMoonPack(pack)); + assert.throws(() => validateMoonPack({ ...pack, mirror: true }), /mirror/); + assert.throws(() => validateMoonPack({ ...pack, disc: { cx: 256, cy: 256, r: 256 } }), /disc/); + const svg = readFileSync(new URL('../assets/moon/houseplan-1.0.0/moon.svg', import.meta.url), 'utf8'); + const art = moonArtFromSvg(svg); + assert.doesNotMatch(art, /<title|id="[abc]"/); + assert.match(art, /url\(#hp-moon-a\)/); + const root = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">'; + assert.throws(() => moonArtFromSvg(`${root}<filter id="f"><feGaussianBlur/></filter></svg>`), /<filter>/); + assert.throws(() => moonArtFromSvg(`${root}<image href="x.png"/></svg>`), /outside itself|<image>/); + assert.throws(() => moonArtFromSvg(`${root.replace('512 512', '512 400')}</svg>`), /viewBox/); + assert.throws(() => moonArtFromSvg(`${root}<path d="${'M0 0'.repeat(9000)}"/></svg>`), /larger/); +}); + +test('#661 C7: the chunk is asked for at night only; until it arrives there is no moon', async () => { + const at = new Date('2026-10-21T18:00:00Z'); + const sunAt = (elevation) => resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 0, elevation, rising: false } } } }, at); + const host = fakeHost(MOSCOW); + assert.equal(moonLayer(host, ON, sunAt(40)), nothing, 'daytime: nothing and no load'); + await new Promise((done) => setTimeout(done, 50)); + assert.equal(host.updates, 0, 'nothing was loaded by day'); + assert.equal(moonLayer(host, ON, sunAt(-12)), nothing, 'the first night render has no moon yet'); + await new Promise((done) => setTimeout(done, 200)); + assert.equal(host.updates, 1, 'the host that asked re-renders when the chunk arrives'); + const element = moonLayer(host, ON, sunAt(-12)); + assert.notEqual(element, nothing); + forgetHost(host); +}); diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index 10e0e36e..596e1f8c 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -603,6 +603,21 @@ def test_issue_577_full_export_preserves_sun_ray_origin(tmp_path: Path) -> None: assert parsed["payload"]["config"]["settings"]["sun_ray_origin"] == "outer" +def test_issue_661_full_export_and_import_preserve_the_moon(tmp_path: Path) -> None: + """#661 AC9: `settings.moon` travels with a full backup both ways.""" + runtime = SimpleNamespace(instance_id="instance-a") + for value in (True, False): + config = _config() + config["settings"]["moon"] = value + document, _ = create_export( + runtime, {"config": config}, {"layout": {}}, + kind="full", space_id=None, card_version="1.79.0", config_root=tmp_path, + ) + assert document["payload"]["config"]["settings"]["moon"] is value + parsed = parse_document(json.dumps(document).encode()) + assert parsed["payload"]["config"]["settings"]["moon"] is value + + @pytest.mark.parametrize( ("kind", "plan_only"), [("full", False), ("space", False), ("space", True)], diff --git a/tests_backend/test_settings_moon.py b/tests_backend/test_settings_moon.py new file mode 100644 index 00000000..e65ea605 --- /dev/null +++ b/tests_backend/test_settings_moon.py @@ -0,0 +1,41 @@ +"""#661 AC9: `settings.moon`, the moon on the "Follow the Sun" background. + +Pure subset (no Home Assistant, runs on Windows): the schema takes a strict +boolean, the support package copies only a boolean, new installations get the +moon switched on. The export/import round trip needs the integration package +and lives in test_ha_import_export.py. +""" +import pytest +import voluptuous as vol + +from pure_imports import HOUSEPLAN_ROOT, load_pure + +v = load_pure("hp_validation_moon", HOUSEPLAN_ROOT / "validation.py") +const = load_pure("custom_components.houseplan.const", HOUSEPLAN_ROOT / "const.py") +support = load_pure( + "custom_components.houseplan.support_package", HOUSEPLAN_ROOT / "support_package.py", +) + + +@pytest.mark.parametrize("value", [True, False]) +def test_moon_accepts_a_boolean_and_keeps_it(value): + out = v.CONFIG_SCHEMA({"spaces": [], "settings": {"moon": value}}) + assert out["settings"]["moon"] is value + + +@pytest.mark.parametrize("bad", ["yes", 1, 0, None, "true", [], {}]) +def test_moon_rejects_everything_but_a_boolean(bad): + with pytest.raises(vol.Invalid): + v.CONFIG_SCHEMA({"spaces": [], "settings": {"moon": bad}}) + + +def test_support_package_copies_only_a_boolean_moon(): + assert support._global_settings({"moon": True}) == {"moon": True} + assert support._global_settings({"moon": False}) == {"moon": False} + for bad in ("true", 1, None): + assert "moon" not in support._global_settings({"moon": bad}) + + +def test_new_installations_get_the_moon(): + assert const.DEFAULT_CONFIG["settings"]["moon"] is True + assert v.CONFIG_SCHEMA(const.DEFAULT_CONFIG)["settings"]["moon"] is True diff --git a/tsconfig.test.json b/tsconfig.test.json index 2604fcf0..56facba1 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -16,7 +16,7 @@ "src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-geometry.ts", "src/zigbee-topology-runtime.ts", "src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", "src/radar-render.ts", "src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts", - "src/sun.ts", + "src/sun.ts", "src/moon.ts", "src/moon-gate.ts", "src/moon-runtime.ts", "src/moon-art.generated.ts", "src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts", "src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts", "src/rules.ts",