mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
SUN: backend validation + card render, dialogs, i18n, smoke (docs/SUN.md)
- validation.py: north_deg (strict int 0-359), bg_mode, sun_rays at both levels + weather_entity (global); tests_backend coverage - card: memoised window-wedge layer (recomputes only on sun/config change), day/night stage background with slow CSS transition and ~10% plan dim, compass dial + number input in the general settings, per-space bg_mode/north_deg/sun_rays overrides (empty = inherit), weather datalist - static space-card: background honours bg_mode (wedges full-card-only) - i18n en/ru, styles, docs/TESTING.md checklist - demo/smoke_sun.mjs: 4-wall windows, compass rotation, night, clipping, inheritance, clouds, memo, editors clean, real compass drag; verified to FAIL against the pre-feature build (16 named failures)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -168,6 +168,22 @@ ROOM_SCHEMA = vol.All(
|
||||
),
|
||||
_require_geometry,
|
||||
)
|
||||
|
||||
def _north_deg(value):
|
||||
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
|
||||
|
||||
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
|
||||
int in Python — none of those is a compass reading a client stored.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
if not 0 <= value <= 359:
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
return value
|
||||
|
||||
|
||||
_BG_MODE = vol.In(["static", "daynight"])
|
||||
|
||||
SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("show_borders"): bool,
|
||||
@@ -185,6 +201,10 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("label_lqi"): bool,
|
||||
vol.Optional("label_light"): bool,
|
||||
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
|
||||
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
|
||||
vol.Optional("north_deg"): vol.Any(None, _north_deg),
|
||||
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
|
||||
vol.Optional("sun_rays"): vol.Any(None, bool),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -323,6 +343,11 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
|
||||
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
|
||||
// day/night background vs static, per-space inheritance, cloud fading,
|
||||
// memoisation across hass ticks, editors stay clean.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const setWeather = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
|
||||
entity_id: 'weather.home', state, attributes: {},
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const cfg = () => c._serverCfg;
|
||||
const touchCfg = () => { c._cfgRev = (c._cfgRev || 0) + 1; };
|
||||
const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
|
||||
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || '';
|
||||
|
||||
// ---- the test plan: windows on all four OUTER walls + one interior ----
|
||||
const sp = cfg().spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 }, // top of r1
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
|
||||
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
|
||||
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
|
||||
];
|
||||
touchCfg();
|
||||
|
||||
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
|
||||
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.silentWithoutNorth = domPolys().length === 0;
|
||||
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
|
||||
|
||||
// 2) compass set → morning east sun lights ONLY the east window
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.morningEast = JSON.stringify(litIds()) === '["wE"]';
|
||||
out.morningDomDrawn = domPolys().length > 0;
|
||||
|
||||
// 3) noon south → only the south window, wedge shorter than the morning one
|
||||
const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
|
||||
await setSun(180, 60);
|
||||
out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
|
||||
out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
|
||||
|
||||
// 4) evening west → only the west window; wedge stays inside its room r1
|
||||
await setSun(270, 4);
|
||||
out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
|
||||
const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
|
||||
out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
|
||||
x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
|
||||
|
||||
// 5) night → nothing
|
||||
await setSun(90, -10);
|
||||
out.nightEmpty = domPolys().length === 0;
|
||||
|
||||
// 6) rotated compass: the same morning east sun now lights the NORTH window
|
||||
cfg().settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
|
||||
// 7) memoisation: an unrelated hass tick must NOT recompute the wedges
|
||||
await setSun(90, 5);
|
||||
const cacheBefore = c._sunRaysCache;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
|
||||
await upd();
|
||||
out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
|
||||
await setSun(91, 5);
|
||||
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
|
||||
|
||||
// 8) day/night background: daynight breathes, static does not
|
||||
await setSun(180, 60);
|
||||
out.staticBgAtNoon = !stageStyle().includes('background:#5a6673');
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
await setSun(180, 60);
|
||||
out.daynightNoonBg = stageStyle().includes('background:#5a6673');
|
||||
out.daynightClass = !!sr().querySelector('.stage.daynight');
|
||||
await setSun(180, -20);
|
||||
out.daynightNightBg = stageStyle().includes('background:#070c14');
|
||||
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
cfg().settings.bg_mode = 'static';
|
||||
touchCfg();
|
||||
await setSun(180, -20);
|
||||
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
|
||||
delete cfg().settings.bg_mode;
|
||||
touchCfg();
|
||||
|
||||
// 9) per-space inheritance: the space override wins over the global flag
|
||||
sp.settings = { ...(sp.settings || {}), sun_rays: false };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceOverridesOff = domPolys().length === 0;
|
||||
delete sp.settings.sun_rays;
|
||||
// and a per-space compass override redirects the light
|
||||
sp.settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
|
||||
delete sp.settings.north_deg;
|
||||
touchCfg();
|
||||
|
||||
// 10) clouds: rain kills the wedges, cloudy only fades them
|
||||
cfg().settings.weather_entity = 'weather.home';
|
||||
touchCfg();
|
||||
await setWeather('pouring');
|
||||
await setSun(90, 5);
|
||||
out.rainKillsRays = domPolys().length === 0;
|
||||
await setWeather('cloudy');
|
||||
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|
||||
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.18 * 0.4) < 1e-6;
|
||||
await setWeather('sunny');
|
||||
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.clearFullAlpha = Math.abs(stopClear - 0.18) < 1e-6;
|
||||
delete cfg().settings.weather_entity;
|
||||
touchCfg();
|
||||
|
||||
// 11) feature off: without the flag nothing is drawn even with sun+north
|
||||
delete cfg().settings.sun_rays;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.offDrawsNothing = domPolys().length === 0;
|
||||
cfg().settings.sun_rays = true;
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
|
||||
// 12) editors stay clean: no wedges, no day/night canvas
|
||||
await setSun(90, 5);
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
out.editorNoRays = domPolys().length === 0;
|
||||
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
out.viewRaysBack = domPolys().length > 0;
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._openSettingsDialog();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
const box = await page.evaluate(() => {
|
||||
const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
|
||||
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
|
||||
});
|
||||
await page.mouse.move(box.cx + 45, box.cy); // on the ring, due right = 90°
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.cx, box.cy + 45); // drag on to due down = 180°
|
||||
await page.mouse.up();
|
||||
const dlg = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot;
|
||||
const out = {
|
||||
compassDragSets180: c._settingsDialog?.northDeg === 180,
|
||||
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
|
||||
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
|
||||
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
|
||||
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
|
||||
};
|
||||
c._settingsDialog = null;
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, dlg);
|
||||
await finish(browser, checkAll(res));
|
||||
File diff suppressed because one or more lines are too long
Vendored
+231
-66
File diff suppressed because one or more lines are too long
@@ -866,3 +866,34 @@ require hands on real hardware — they remain for the human pass.
|
||||
one release = one undo step (rooms AND openings)
|
||||
- [ ] Device markers do not move; the room settings gear re-centres itself
|
||||
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
|
||||
|
||||
## Sun on the plan (docs/SUN.md)
|
||||
|
||||
- [ ] Everything below is INERT until `north_deg` is set (general ⚙ or the
|
||||
space settings) AND the install has `sun.sun`; both dialogs hint at
|
||||
whichever is missing
|
||||
- [ ] The ⚙ compass: dragging the «N» arrow turns it in 1° steps (15° with
|
||||
Shift); the number field mirrors the dial and accepts 0–359; «Clear»
|
||||
returns the unset state
|
||||
- [ ] «Plan background» selector: `static` keeps the existing color picker
|
||||
and behaviour byte-for-byte; `daynight` hides the picker and the stage
|
||||
follows the sun — neutral day, warm golden hour, dark night — with a
|
||||
slow (tens of seconds) transition; the PLAN dims only ~10% at night
|
||||
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
|
||||
when empty, exactly like show_lqi/fill_mode
|
||||
- [ ] «Sunlight through windows» (default OFF): wedges appear only from
|
||||
windows on EXTERIOR walls facing the sun; interior windows, open
|
||||
(virtual) boundaries and doors never light up
|
||||
- [ ] Wedge direction follows the compass; length grows toward
|
||||
sunrise/sunset and shrinks toward noon; every wedge is clipped by its
|
||||
room polygon; night = no wedges at all
|
||||
- [ ] Weather entity (optional, global): cloudy fades the wedges, rain/snow
|
||||
removes them, a dead/unknown weather sensor changes nothing
|
||||
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
|
||||
change — an unrelated `hass` tick reuses the memo
|
||||
- [ ] Editors (plan/devices/decor) render with NO wedges and NO day/night;
|
||||
kiosk works; the static space-card honours the background mode
|
||||
(wedges are full-card-only in v1)
|
||||
- [ ] `prefers-reduced-motion` → no transitions, static colors
|
||||
- [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`;
|
||||
backend: `tests_backend/test_validation.py` (sun settings)
|
||||
|
||||
+295
-19
@@ -28,6 +28,10 @@ import {
|
||||
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
|
||||
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
|
||||
} from './resize';
|
||||
import {
|
||||
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
|
||||
sunStateOf, cloudFactor, rayAlpha, rayColor, type SunRay,
|
||||
} from './sun';
|
||||
import { ContentSigner } from './signing';
|
||||
import { mdiHomeCityOutline } from '@mdi/js';
|
||||
import {
|
||||
@@ -326,7 +330,15 @@ class HouseplanCard extends LitElement {
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
|
||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||
private _settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; busy: boolean } | null = null;
|
||||
private _settingsDialog: {
|
||||
colors: FillColors; glowRadius: number; bgColor: string | null;
|
||||
/** sun on the plan (docs/SUN.md) */
|
||||
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean; weatherEntity: string;
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
/** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
|
||||
private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
|
||||
private _compassDrag = false;
|
||||
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
|
||||
private _importQueue: string[] = []; // floor titles still to create
|
||||
private _importTotal = 0;
|
||||
@@ -396,6 +408,9 @@ class HouseplanCard extends LitElement {
|
||||
roomColor: string;
|
||||
roomOpacity: number; // 0..1
|
||||
bgColor: string | null; // background around the plan; null = inherit general
|
||||
bgMode: 'static' | 'daynight' | null; // plan background mode; null = inherit (docs/SUN.md)
|
||||
northDeg: number | null; // per-space compass override; null = inherit
|
||||
sunRays: boolean | null; // per-space wedges override; null = inherit
|
||||
fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
@@ -3900,6 +3915,9 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
bgColor: disp.bgColor,
|
||||
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null,
|
||||
northDeg: northDegOf({}, sp.settings),
|
||||
sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
|
||||
cardFontScale: disp.cardFontScale,
|
||||
@@ -3915,6 +3933,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
|
||||
bgColor: null,
|
||||
bgMode: null, northDeg: null, sunRays: null,
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this._config?.show_signal ?? true,
|
||||
cardFontScale: 1,
|
||||
@@ -4143,6 +4162,9 @@ class HouseplanCard extends LitElement {
|
||||
room_color: d.roomColor,
|
||||
room_opacity: d.roomOpacity,
|
||||
bg_color: d.bgColor || undefined, // empty = inherit the general setting
|
||||
bg_mode: d.bgMode || undefined, // sun on the plan (docs/SUN.md); empty = inherit
|
||||
north_deg: d.northDeg ?? undefined,
|
||||
sun_rays: d.sunRays ?? undefined,
|
||||
fill_mode: d.fillMode,
|
||||
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
|
||||
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
|
||||
@@ -4250,6 +4272,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
|
||||
bgColor: null,
|
||||
bgMode: null, northDeg: null, sunRays: null,
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this._config?.show_signal ?? true,
|
||||
cardFontScale: 1,
|
||||
@@ -4305,12 +4328,148 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ================= SUN ON THE PLAN (docs/SUN.md) =================
|
||||
|
||||
/** Global settings with the general-settings dialog's pending values. */
|
||||
private _sunGlobal(): any {
|
||||
const gd = this._settingsDialog;
|
||||
if (!gd) return this._settings;
|
||||
return {
|
||||
...this._settings,
|
||||
north_deg: gd.northDeg ?? undefined,
|
||||
bg_mode: gd.bgMode,
|
||||
sun_rays: gd.sunRays,
|
||||
weather_entity: (gd.weatherEntity || '').trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Current space settings with the space dialog's pending values. */
|
||||
private _sunSpace(): any {
|
||||
const sd = this._spaceDialog;
|
||||
const saved = this._curSpaceCfg?.settings || {};
|
||||
if (!sd || sd.mode !== 'edit' || sd.spaceId !== this._space) return saved;
|
||||
return {
|
||||
...saved,
|
||||
north_deg: sd.northDeg ?? undefined,
|
||||
bg_mode: sd.bgMode ?? undefined,
|
||||
sun_rays: sd.sunRays ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Effective compass; null = the whole sun feature is inert (docs/SUN.md). */
|
||||
private _effNorth(): number | null {
|
||||
return northDegOf(this._sunGlobal(), this._sunSpace());
|
||||
}
|
||||
|
||||
private _effBgMode(): 'static' | 'daynight' {
|
||||
return bgModeOf(this._sunGlobal(), this._sunSpace());
|
||||
}
|
||||
|
||||
private _effSunRays(): boolean {
|
||||
return sunRaysOn(this._sunGlobal(), this._sunSpace());
|
||||
}
|
||||
|
||||
/** sun.sun, but only when the feature is armed (north_deg set somewhere). */
|
||||
private _sunNow(): { azimuth: number; elevation: number } | null {
|
||||
return this._effNorth() !== null ? sunStateOf(this.hass) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window light wedges (docs/SUN.md): view/kiosk only. Geometry recomputes
|
||||
* ONLY when the memo key changes — sun attributes tick every ~30-120 s and
|
||||
* everything else in `hass` must not trigger the polygon clipping.
|
||||
*/
|
||||
private _renderSunRays(space: SpaceModel): TemplateResult {
|
||||
const empty = svg`` as unknown as TemplateResult;
|
||||
if (this._editing || !this._effSunRays()) return empty;
|
||||
const north = this._effNorth();
|
||||
const sun = north !== null ? sunStateOf(this.hass) : null;
|
||||
if (!sun || sun.elevation <= 0) return empty;
|
||||
const weather = weatherEntityOf(this._sunGlobal());
|
||||
const cloud = cloudFactor(weather ? this.hass?.states?.[weather]?.state : null);
|
||||
const alpha = rayAlpha(sun.elevation, cloud);
|
||||
if (alpha <= 0) return empty;
|
||||
const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgRev}`;
|
||||
if (!this._sunRaysCache || this._sunRaysCache.key !== key) {
|
||||
const rooms = space.rooms
|
||||
.map((r) => ({ id: r.id || '', poly: roomPoly(r) }))
|
||||
.filter((r): r is { id: string; poly: number[][] } => !!r.id && !!r.poly);
|
||||
const windows = this._openingsR
|
||||
.filter((o) => o.type === 'window')
|
||||
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
|
||||
this._sunRaysCache = { key, rays: computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!) };
|
||||
}
|
||||
const rays = this._sunRaysCache.rays;
|
||||
if (!rays.length) return empty;
|
||||
const color = rayColor(dayPhase(sun.elevation).warmth);
|
||||
return svg`<defs>
|
||||
${rays.map((r, i) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2;
|
||||
const my = (r.a[1] + r.b[1]) / 2;
|
||||
return svg`<linearGradient id="hp-sun-${i}" gradientUnits="userSpaceOnUse"
|
||||
x1="${mx}" y1="${my}" x2="${mx + r.dir[0] * r.len}" y2="${my + r.dir[1] * r.len}">
|
||||
<stop offset="0%" stop-color="${color}" stop-opacity="${alpha.toFixed(3)}"></stop>
|
||||
<stop offset="100%" stop-color="${color}" stop-opacity="0"></stop>
|
||||
</linearGradient>`;
|
||||
})}
|
||||
</defs>
|
||||
<g class="sunlayer">
|
||||
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
|
||||
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/** One drag/click sample on the compass dial → dialog north_deg. */
|
||||
private _compassPoint(ev: PointerEvent): void {
|
||||
const el = ev.currentTarget as SVGSVGElement;
|
||||
const r = el.getBoundingClientRect();
|
||||
const dx = ev.clientX - (r.left + r.width / 2);
|
||||
const dy = ev.clientY - (r.top + r.height / 2);
|
||||
if (Math.hypot(dx, dy) < 5) return; // the dead centre says nothing about angle
|
||||
let a = Math.round((Math.atan2(dx, -dy) * 180) / Math.PI);
|
||||
if (ev.shiftKey) a = Math.round(a / 15) * 15; // coarse 15° steps with Shift
|
||||
a = ((a % 360) + 360) % 360;
|
||||
this._settingsDialog = { ...this._settingsDialog!, northDeg: a };
|
||||
}
|
||||
|
||||
/** The compass dial: drag the «N» arrow around the ring (docs/SUN.md). */
|
||||
private _renderCompass(): TemplateResult {
|
||||
const d = this._settingsDialog!;
|
||||
const deg = d.northDeg;
|
||||
return html`<svg class="compass ${deg === null ? 'unset' : ''}" viewBox="-60 -60 120 120"
|
||||
@pointerdown=${(e: PointerEvent) => {
|
||||
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||
this._compassDrag = true;
|
||||
this._compassPoint(e);
|
||||
}}
|
||||
@pointermove=${(e: PointerEvent) => { if (this._compassDrag) this._compassPoint(e); }}
|
||||
@pointerup=${() => (this._compassDrag = false)}
|
||||
@pointercancel=${() => (this._compassDrag = false)}>
|
||||
<circle class="cring" r="50"></circle>
|
||||
${[0, 45, 90, 135, 180, 225, 270, 315].map(
|
||||
(t) => svg`<line class="ctick ${t % 90 ? 'minor' : ''}" x1="0" y1="-50" x2="0" y2="${t % 90 ? -46 : -43}"
|
||||
transform="rotate(${t})"></line>`,
|
||||
)}
|
||||
<g class="cneedle" transform="rotate(${deg ?? 0})">
|
||||
<line x1="0" y1="34" x2="0" y2="-28"></line>
|
||||
<path d="M -7 -24 L 0 -42 L 7 -24 Z"></path>
|
||||
<text x="0" y="-12" text-anchor="middle">${this._t('gs.north_letter')}</text>
|
||||
</g>
|
||||
<text class="cdeg" x="0" y="26" text-anchor="middle">${deg === null ? '—' : deg + '°'}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective stage background: per-space override → global setting → '' (the
|
||||
* theme default from the stylesheet). An open dialog previews its pending
|
||||
* value, so the color can be picked against the live plan.
|
||||
* value, so the color can be picked against the live plan. In 'daynight'
|
||||
* mode (docs/SUN.md) the sun's elevation paints the stage instead.
|
||||
*/
|
||||
private _stageBg(disp: SpaceDisplay): string {
|
||||
if (this._effBgMode() === 'daynight') {
|
||||
const sun = this._sunNow();
|
||||
if (sun) return dayPhase(sun.elevation).bg;
|
||||
}
|
||||
const gd = this._settingsDialog;
|
||||
const sd = this._spaceDialog;
|
||||
const globalBg = gd ? gd.bgColor || '' : stageBgOf(this._settings, { bgColor: null });
|
||||
@@ -4343,6 +4502,10 @@ class HouseplanCard extends LitElement {
|
||||
colors: JSON.parse(JSON.stringify(this._fillColors)),
|
||||
glowRadius,
|
||||
bgColor: stageBgOf(this._settings, { bgColor: null }) || null,
|
||||
northDeg: northDegOf(this._settings, {}),
|
||||
bgMode: bgModeOf(this._settings, {}),
|
||||
sunRays: sunRaysOn(this._settings, {}),
|
||||
weatherEntity: weatherEntityOf(this._settings) || '',
|
||||
busy: false,
|
||||
};
|
||||
};
|
||||
@@ -4367,6 +4530,17 @@ class HouseplanCard extends LitElement {
|
||||
else delete settings.glow_radius_cm;
|
||||
if (d.bgColor) settings.bg_color = d.bgColor;
|
||||
else delete settings.bg_color;
|
||||
// sun on the plan (docs/SUN.md): defaults are never stored
|
||||
if (d.northDeg !== null && Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359)
|
||||
settings.north_deg = d.northDeg;
|
||||
else delete settings.north_deg;
|
||||
if (d.bgMode === 'daynight') settings.bg_mode = 'daynight';
|
||||
else delete settings.bg_mode;
|
||||
if (d.sunRays) settings.sun_rays = true;
|
||||
else delete settings.sun_rays;
|
||||
const we = (d.weatherEntity || '').trim();
|
||||
if (we) settings.weather_entity = we;
|
||||
else delete settings.weather_entity;
|
||||
this._serverCfg = { ...cfg, settings };
|
||||
await this._saveConfigNow();
|
||||
this._settingsDialog = null;
|
||||
@@ -4521,19 +4695,78 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
<label class="dispsection">${this._t('gs.bg_group')}</label>
|
||||
<div class="colorrow gsrow">
|
||||
<span class="gsl">${this._t('gs.bg_color')}</span>
|
||||
<input type="color" .value=${this._settingsDialog!.bgColor || this._stageBgHex()}
|
||||
<span class="gsl">${this._t('gs.bg_mode')}</span>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, bgMode: (e.target as HTMLSelectElement).value === 'daynight' ? 'daynight' : 'static' })}>
|
||||
<option value="static" ?selected=${this._settingsDialog!.bgMode === 'static'}>${this._t('gs.bg_static')}</option>
|
||||
<option value="daynight" ?selected=${this._settingsDialog!.bgMode === 'daynight'}>${this._t('gs.bg_daynight')}</option>
|
||||
</select>
|
||||
</div>
|
||||
${this._settingsDialog!.bgMode === 'static'
|
||||
? html`<div class="colorrow gsrow">
|
||||
<span class="gsl">${this._t('gs.bg_color')}</span>
|
||||
<input type="color" .value=${this._settingsDialog!.bgColor || this._stageBgHex()}
|
||||
@input=${(e: Event) =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, bgColor: (e.target as HTMLInputElement).value })} />
|
||||
${this._settingsDialog!.bgColor
|
||||
? html`<button class="btn ghost" @click=${() =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, bgColor: null })}>${this._t('gs.bg_default')}</button>`
|
||||
: html`<span class="opl">${this._t('gs.bg_theme')}</span>`}
|
||||
</div>`
|
||||
: html`<div class="rhint">${this._t('gs.bg_daynight_hint')}</div>`}
|
||||
<label class="dispsection">${this._t('gs.sun_group')}</label>
|
||||
${!sunStateOf(this.hass)
|
||||
? html`<div class="rhint">${this._t('gs.sun_missing')}</div>`
|
||||
: nothing}
|
||||
<div class="sunrow">
|
||||
${this._renderCompass()}
|
||||
<div class="suncol">
|
||||
<span class="gsl">${this._t('gs.north')}</span>
|
||||
<div class="colorrow">
|
||||
<input class="namein tempin" type="number" min="0" max="359" step="1"
|
||||
placeholder=${this._t('gs.north_ph')}
|
||||
.value=${this._settingsDialog!.northDeg === null ? '' : String(this._settingsDialog!.northDeg)}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value.trim();
|
||||
const n = raw === '' ? null : Math.round(Number(raw));
|
||||
this._settingsDialog = {
|
||||
...this._settingsDialog!,
|
||||
northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null,
|
||||
};
|
||||
}} />
|
||||
${this._settingsDialog!.northDeg !== null
|
||||
? html`<button class="btn ghost" @click=${() =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, northDeg: null })}>${this._t('gs.north_clear')}</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._settingsDialog!.northDeg === null
|
||||
? html`<div class="rhint">${this._t('gs.north_hint')}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<label class="srcrow">
|
||||
<input type="checkbox" .checked=${this._settingsDialog!.sunRays}
|
||||
@change=${(e: Event) =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, sunRays: (e.target as HTMLInputElement).checked })} />
|
||||
<span>${this._t('gs.sun_rays')}</span>
|
||||
</label>
|
||||
<div class="colorrow gsrow">
|
||||
<span class="gsl">${this._t('gs.weather')}</span>
|
||||
<input class="namein" type="text" list="hp-weather-list" placeholder=${this._t('gs.weather_ph')}
|
||||
.value=${this._settingsDialog!.weatherEntity}
|
||||
@input=${(e: Event) =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, bgColor: (e.target as HTMLInputElement).value })} />
|
||||
${this._settingsDialog!.bgColor
|
||||
? html`<button class="btn ghost" @click=${() =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, bgColor: null })}>${this._t('gs.bg_default')}</button>`
|
||||
: html`<span class="opl">${this._t('gs.bg_theme')}</span>`}
|
||||
(this._settingsDialog = { ...this._settingsDialog!, weatherEntity: (e.target as HTMLInputElement).value })} />
|
||||
<datalist id="hp-weather-list">
|
||||
${Object.keys(this.hass?.states || {}).filter((id) => id.startsWith('weather.')).map(
|
||||
(id) => html`<option value=${id}></option>`,
|
||||
)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn ghost" @click=${() =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null })}>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'static', sunRays: false, weatherEntity: '' })}>
|
||||
${this._t('gs.reset')}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
@@ -4730,6 +4963,9 @@ class HouseplanCard extends LitElement {
|
||||
// Background around the plan (view/kiosk; editors keep their own canvas).
|
||||
// Both settings dialogs preview their pending value live.
|
||||
const stageBg = this._editing ? '' : this._stageBg(disp);
|
||||
// day/night breathing: armed only with a compass AND sun.sun (docs/SUN.md)
|
||||
const dayNight = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
|
||||
const planDim = dayNight ? dayPhase(dayNight.elevation).planDim : 0;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -4800,7 +5036,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
|
||||
</div>
|
||||
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
|
||||
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
@@ -4808,7 +5044,8 @@ class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
|
||||
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
|
||||
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}">
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
|
||||
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
${this._editing ? this._renderMarkupDefs(vb) : nothing}
|
||||
${this._editing && !this._markup
|
||||
@@ -4909,6 +5146,7 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
})()}
|
||||
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||
${this._renderSunRays(space)}
|
||||
${this._renderOpenWalls(disp)}
|
||||
${this._editing ? this._renderAlignGuides() : nothing}
|
||||
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||
@@ -6905,15 +7143,53 @@ class HouseplanCard extends LitElement {
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, roomOpacity: Number((e.target as HTMLInputElement).value) / 100 })} />
|
||||
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
|
||||
</div>
|
||||
<label>${this._t('space.bg_color')}</label>
|
||||
<label>${this._t('space.bg_mode')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
this._spaceDialog = { ...d, bgMode: v === 'static' || v === 'daynight' ? (v as any) : null };
|
||||
}}>
|
||||
<option value="" ?selected=${d.bgMode === null}>${this._t('space.sun_inherit')}</option>
|
||||
<option value="static" ?selected=${d.bgMode === 'static'}>${this._t('gs.bg_static')}</option>
|
||||
<option value="daynight" ?selected=${d.bgMode === 'daynight'}>${this._t('gs.bg_daynight')}</option>
|
||||
</select>
|
||||
${(d.bgMode ?? bgModeOf(this._settings, {})) === 'static'
|
||||
? html`<label>${this._t('space.bg_color')}</label>
|
||||
<div class="colorrow">
|
||||
<input type="color" .value=${d.bgColor || stageBgOf(this._settings, { bgColor: null }) || this._stageBgHex()}
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, bgColor: (e.target as HTMLInputElement).value })} />
|
||||
${d.bgColor
|
||||
? html`<button class="btn ghost" @click=${() => (this._spaceDialog = { ...d, bgColor: null })}>
|
||||
${this._t('space.bg_inherit')}</button>`
|
||||
: html`<span class="opl">${this._t('space.bg_inherited')}</span>`}
|
||||
</div>`
|
||||
: nothing}
|
||||
<label>${this._t('space.north')}</label>
|
||||
<div class="colorrow">
|
||||
<input type="color" .value=${d.bgColor || stageBgOf(this._settings, { bgColor: null }) || this._stageBgHex()}
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, bgColor: (e.target as HTMLInputElement).value })} />
|
||||
${d.bgColor
|
||||
? html`<button class="btn ghost" @click=${() => (this._spaceDialog = { ...d, bgColor: null })}>
|
||||
${this._t('space.bg_inherit')}</button>`
|
||||
: html`<span class="opl">${this._t('space.bg_inherited')}</span>`}
|
||||
<input class="namein tempin" type="number" min="0" max="359" step="1"
|
||||
placeholder=${this._t('space.sun_inherit')}
|
||||
.value=${d.northDeg === null ? '' : String(d.northDeg)}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value.trim();
|
||||
const n = raw === '' ? null : Math.round(Number(raw));
|
||||
this._spaceDialog = { ...d, northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null };
|
||||
}} />
|
||||
<span class="opl">${d.northDeg === null
|
||||
? this._t('space.north_inherited', {
|
||||
v: northDegOf(this._settings, {}) === null ? '—' : String(northDegOf(this._settings, {})) + '°',
|
||||
})
|
||||
: '°'}</span>
|
||||
</div>
|
||||
<label>${this._t('space.sun_rays')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
this._spaceDialog = { ...d, sunRays: v === '' ? null : v === '1' };
|
||||
}}>
|
||||
<option value="" ?selected=${d.sunRays === null}>${this._t('space.sun_inherit')}</option>
|
||||
<option value="1" ?selected=${d.sunRays === true}>${this._t('space.sun_on')}</option>
|
||||
<option value="0" ?selected=${d.sunRays === false}>${this._t('space.sun_off')}</option>
|
||||
</select>
|
||||
<label>${this._t('space.fill_label')}</label>
|
||||
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
|
||||
([v, k]) => html`<label class="srcrow">
|
||||
|
||||
+22
-1
@@ -384,7 +384,28 @@
|
||||
"gs.bg_color": "Background around the plan",
|
||||
"gs.bg_default": "Theme default",
|
||||
"gs.bg_theme": "theme default",
|
||||
"gs.bg_mode": "Plan background",
|
||||
"gs.bg_static": "Static color",
|
||||
"gs.bg_daynight": "Follows the sun (day/night)",
|
||||
"gs.bg_daynight_hint": "The stage follows the sun: neutral day, warm golden hour, deep night. Needs the compass below.",
|
||||
"gs.sun_group": "Sun",
|
||||
"gs.sun_missing": "The sun.sun entity was not found — the sun features stay off.",
|
||||
"gs.north": "North on the plan",
|
||||
"gs.north_ph": "not set",
|
||||
"gs.north_hint": "Point the arrow at north (1° steps, 15° with Shift) or type the degrees — until then the sun features stay off.",
|
||||
"gs.north_clear": "Clear",
|
||||
"gs.north_letter": "N",
|
||||
"gs.sun_rays": "Sunlight through windows",
|
||||
"gs.weather": "Weather entity",
|
||||
"gs.weather_ph": "weather.home (optional)",
|
||||
"space.bg_color": "Background around the plan",
|
||||
"space.bg_inherit": "Inherit general",
|
||||
"space.bg_inherited": "inherits general settings"
|
||||
"space.bg_inherited": "inherits general settings",
|
||||
"space.bg_mode": "Plan background",
|
||||
"space.north": "North on the plan (override)",
|
||||
"space.north_inherited": "inherited: {v}",
|
||||
"space.sun_rays": "Sunlight through windows",
|
||||
"space.sun_inherit": "Inherit general",
|
||||
"space.sun_on": "On",
|
||||
"space.sun_off": "Off"
|
||||
}
|
||||
|
||||
+22
-1
@@ -384,7 +384,28 @@
|
||||
"gs.bg_color": "Цвет фона вокруг плана",
|
||||
"gs.bg_default": "Как в теме",
|
||||
"gs.bg_theme": "по умолчанию — из темы",
|
||||
"gs.bg_mode": "Фон плана",
|
||||
"gs.bg_static": "Статичный цвет",
|
||||
"gs.bg_daynight": "Следует за солнцем (день/ночь)",
|
||||
"gs.bg_daynight_hint": "Фон следует за солнцем: нейтральный день, тёплый золотой час, глубокая ночь. Нужен компас ниже.",
|
||||
"gs.sun_group": "Солнце",
|
||||
"gs.sun_missing": "Сущность sun.sun не найдена — солнечные функции выключены.",
|
||||
"gs.north": "Север на плане",
|
||||
"gs.north_ph": "не задан",
|
||||
"gs.north_hint": "Направьте стрелку на север (шаг 1°, с Shift 15°) или введите градусы — до этого солнечные функции молчат.",
|
||||
"gs.north_clear": "Сбросить",
|
||||
"gs.north_letter": "С",
|
||||
"gs.sun_rays": "Солнце в окнах",
|
||||
"gs.weather": "Сущность погоды",
|
||||
"gs.weather_ph": "weather.home (необязательно)",
|
||||
"space.bg_color": "Цвет фона вокруг плана",
|
||||
"space.bg_inherit": "Наследовать общий",
|
||||
"space.bg_inherited": "наследуется из общих настроек"
|
||||
"space.bg_inherited": "наследуется из общих настроек",
|
||||
"space.bg_mode": "Фон плана",
|
||||
"space.north": "Север на плане (переопределение)",
|
||||
"space.north_inherited": "наследуется: {v}",
|
||||
"space.sun_rays": "Солнце в окнах",
|
||||
"space.sun_inherit": "Наследовать общий",
|
||||
"space.sun_on": "Вкл",
|
||||
"space.sun_off": "Выкл"
|
||||
}
|
||||
|
||||
+11
-2
@@ -11,6 +11,7 @@ import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
|
||||
import type { ServerConfig } from './types';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
|
||||
@@ -146,8 +147,16 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
: [];
|
||||
|
||||
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
||||
// the static card paints the same background around the plan as the full one
|
||||
const stageBg = stageBgOf(o.cfg?.settings, disp);
|
||||
// The static card paints the same background around the plan as the full
|
||||
// one. In 'daynight' mode (docs/SUN.md) the sun's elevation paints it —
|
||||
// wedges stay full-card-only in v1, the background alone follows the sky.
|
||||
const spaceSettings = (o.cfg.spaces.find((sp: any) => sp.id === o.spaceId) as any)?.settings || {};
|
||||
let sunBg = '';
|
||||
if (bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight' && northDegOf(o.cfg?.settings, spaceSettings) !== null) {
|
||||
const sun = sunStateOf(o.hass);
|
||||
if (sun) sunBg = dayPhase(sun.elevation).bg;
|
||||
}
|
||||
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
|
||||
|
||||
@@ -151,6 +151,85 @@ export const cardStyles = css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
/* Sun on the plan (docs/SUN.md): the stage breathes with the day over tens
|
||||
of seconds; the plan itself dims at most ~10%. Reduced motion = static. */
|
||||
.stage.daynight {
|
||||
transition: background-color 45s linear;
|
||||
}
|
||||
.stage.daynight .zoomwrap {
|
||||
transition: filter 45s linear;
|
||||
}
|
||||
.stage.daynight.hpsettle {
|
||||
transition: height 0.25s ease, background-color 45s linear;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stage.daynight,
|
||||
.stage.daynight .zoomwrap,
|
||||
.stage.daynight.hpsettle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.sunlayer {
|
||||
pointer-events: none;
|
||||
}
|
||||
/* the compass dial in the general settings (docs/SUN.md) */
|
||||
.sunrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.suncol {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.compass {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
flex: none;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.compass:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.compass .cring {
|
||||
fill: rgba(255, 255, 255, 0.04);
|
||||
stroke: var(--divider-color, #444);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.compass .ctick {
|
||||
stroke: var(--secondary-text-color, #9aa4ad);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.compass .ctick.minor {
|
||||
stroke-width: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.compass .cneedle line {
|
||||
stroke: var(--primary-color, #3ea6ff);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.compass .cneedle path {
|
||||
fill: var(--primary-color, #3ea6ff);
|
||||
}
|
||||
.compass .cneedle text {
|
||||
fill: var(--text-primary-color, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.compass .cdeg {
|
||||
fill: var(--secondary-text-color, #9aa4ad);
|
||||
font-size: 13px;
|
||||
}
|
||||
.compass.unset .cneedle {
|
||||
opacity: 0.35;
|
||||
}
|
||||
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
|
||||
.stage.hpboot .zoomwrap,
|
||||
.stage.hpboot .zoombadge {
|
||||
|
||||
@@ -182,6 +182,38 @@ def test_bg_color_setting():
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#1234567"}})
|
||||
|
||||
|
||||
def test_sun_settings_global():
|
||||
"""docs/SUN.md: north_deg strict int 0..359, bg_mode, sun_rays, weather_entity."""
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {
|
||||
"north_deg": 0, "bg_mode": "daynight", "sun_rays": True, "weather_entity": "weather.home",
|
||||
}})
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"north_deg": 359, "bg_mode": "static"}})
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"weather_entity": None}})
|
||||
for bad in (360, -1, 1.5, "90", True, None):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"north_deg": bad}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_mode": "disco"}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"sun_rays": "yes"}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"weather_entity": {"e": 1}}})
|
||||
|
||||
|
||||
def test_sun_settings_per_space():
|
||||
"""The same three at the space level; None/absent = inherit; no weather here."""
|
||||
ok = {"id": "f1", "title": "F", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": 90, "bg_mode": "daynight", "sun_rays": False}))
|
||||
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": None, "bg_mode": None, "sun_rays": None}))
|
||||
for bad in (360, -1, 2.5, "0", True):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": bad}))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(dict(ok, settings={"bg_mode": "auto"}))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(dict(ok, settings={"sun_rays": 1}))
|
||||
|
||||
|
||||
def test_space_temp_bounds():
|
||||
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
|
||||
ok = {
|
||||
|
||||
Reference in New Issue
Block a user