mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
feat(view): the moon in its phase over the "Follow the Sun" background (#661)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e515bdaab8
commit
c8c470ed57
@@ -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.
|
||||
@@ -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 (`<mask>`), 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.
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
+32
-1
@@ -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');
|
||||
|
||||
+14
-1
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- На фоне «Следует за Солнцем» в сумерках и ночью в левом верхнем углу сцены,
|
||||
за планом, теперь видна луна в текущей фазе. Она следует настоящей луне над
|
||||
вашим домом (по координатам дома из Home Assistant), плавно появляется и
|
||||
исчезает при пересечении 3° над горизонтом, днём и в новолуние её нет. В общих
|
||||
настройках раздел «Солнце» стал «Солнце и Луна», в нём новый переключатель,
|
||||
включённый у новых установок
|
||||
([#661](https://github.com/Matysh/houseplan-card/issues/661)).
|
||||
- В объёмном 2.5D-виде значки устройств больше не сдвигаются, когда меняется
|
||||
состояние устройства (например, лампа включилась и показала яркость);
|
||||
обновление Home Assistant в плотном плане больше не переразмещает все значки
|
||||
|
||||
@@ -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)
|
||||
|
||||
+92
@@ -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 `<mask>`, 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
|
||||
|
||||
+29
-2
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
+30
-3
@@ -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. Роботы-пылесосы
|
||||
|
||||
Живая позиция доступна для маркера пылесоса, если источник отдаёт конечные
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -413,6 +413,10 @@
|
||||
"required": true,
|
||||
"type": "str"
|
||||
},
|
||||
"config.settings.moon": {
|
||||
"required": false,
|
||||
"type": "bool"
|
||||
},
|
||||
"config.settings.new_device_ids": {
|
||||
"required": false
|
||||
},
|
||||
|
||||
@@ -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 (/<!DOCTYPE|<!ENTITY|<\?|<!--|<!\[CDATA/i.test(text)) fail('moon.svg contains active XML');
|
||||
if (/href\s*=|url\((?!#)|@import|base64|font-family|style\s*=/i.test(text)) fail('moon.svg references outside itself or carries styles');
|
||||
for (const [, name] of text.matchAll(/<\/?([A-Za-z][\w:-]*)/g)) {
|
||||
if (!ALLOWED_TAGS.has(name)) fail(`moon.svg contains <${name}>`);
|
||||
}
|
||||
const root = text.match(/^<svg\b([^>]*)>([\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(/<title>[\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)}`);
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@
|
||||
"hostRefs": 4880,
|
||||
"portPrivates": 94,
|
||||
"harnessPrivates": 100,
|
||||
"bundleBytes": 2583806
|
||||
"bundleBytes": 2616267
|
||||
}
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+1
-16
@@ -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.",
|
||||
|
||||
+1
-16
@@ -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.",
|
||||
|
||||
+1
-16
@@ -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.",
|
||||
|
||||
@@ -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__';
|
||||
|
||||
|
||||
+1
-16
@@ -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} МБ памяти. На планшетах это может привести к падению страницы.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Цвет заливки стен"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
+156
@@ -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`;
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
+4
-1
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)],
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user