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:
Sergey Matyunin
2026-09-30 12:25:46 +03:00
co-authored by Claude Opus 5.5
parent e515bdaab8
commit c8c470ed57
58 changed files with 1682 additions and 126 deletions
+7 -2
View File
@@ -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>`;
}
+3
View File
@@ -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;
+11
View File
@@ -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;
}
+24 -19
View File
@@ -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
+1 -1
View File
@@ -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;
+5 -4
View File
@@ -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}
+5 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.",
+1 -1
View File
@@ -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
View File
@@ -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} МБ памяти. На планшетах это может привести к падению страницы.",
+18 -1
View File
@@ -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"
}
+18 -1
View File
@@ -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"
}
+18 -1
View File
@@ -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"
}
+18 -1
View File
@@ -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
+50
View File
@@ -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;
}
+122
View File
@@ -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
View File
@@ -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`;
}
+1
View File
@@ -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
View File
@@ -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">
+2
View File
@@ -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;