mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.35.0: glow fill — dark house with glowing light sources
- fill_mode 'glow': uniform darkness over every room; lit lamps render radial gradient pools (rgb_color -> color temp via kelvinToRgb -> configurable default; brightness scales alpha) - pools clipped by the source's room + doorway sectors (doorSector rays to door edges; hasRoomBehind blocks entrance doors); windows and islands don't participate (no shadow casting, documented) - glow radius in HA units (m/ft) in general settings, stored in cm; palette group glow_base/glow_light; backend schema updated - +4 unit tests (98 total); smoke_glow.mjs (10 checks); docs same-commit
This commit is contained in:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.34.0"
|
VERSION = "1.35.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.34.0"
|
"version": "1.35.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("show_names"): bool,
|
vol.Optional("show_names"): bool,
|
||||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
|
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
|
||||||
vol.Optional("temp_min"): vol.Coerce(float),
|
vol.Optional("temp_min"): vol.Coerce(float),
|
||||||
vol.Optional("temp_max"): vol.Coerce(float),
|
vol.Optional("temp_max"): vol.Coerce(float),
|
||||||
vol.Optional("show_lqi"): bool,
|
vol.Optional("show_lqi"): bool,
|
||||||
@@ -186,6 +186,7 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||||
vol.Optional("settings", default=dict): vol.Schema(
|
vol.Optional("settings", default=dict): vol.Schema(
|
||||||
{
|
{
|
||||||
|
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||||
vol.Optional("known_devices"): [str],
|
vol.Optional("known_devices"): [str],
|
||||||
vol.Optional("new_device_ids"): [str],
|
vol.Optional("new_device_ids"): [str],
|
||||||
vol.Optional("fill_colors"): vol.Schema(
|
vol.Optional("fill_colors"): vol.Schema(
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { launch } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
const sr = () => c.shadowRoot || c.renderRoot;
|
||||||
|
const spId = c._space;
|
||||||
|
// включить glow-режим
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||||
|
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
// 1) все комнаты залиты темнотой одинаково (независимо от ламп)
|
||||||
|
const rooms = [...sr().querySelectorAll('.room.styled')];
|
||||||
|
out.roomsDark = rooms.length > 0 && rooms.every((el) => {
|
||||||
|
const st = el.getAttribute('style') || '';
|
||||||
|
return st.includes('--room-fill:#0d1b2a');
|
||||||
|
});
|
||||||
|
// 2) пятна от включённых ламп
|
||||||
|
const litLight = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||||
|
out.hasLitLight = !!litLight;
|
||||||
|
out.spots = sr().querySelectorAll('.glowlayer circle').length;
|
||||||
|
out.spotsMatchLights = out.spots === c._devices.filter((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on')).length;
|
||||||
|
// 3) выключение лампы убирает её пятно
|
||||||
|
const eid = litLight.entities.find((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on');
|
||||||
|
const st0 = c.hass.states[eid];
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'off' } } };
|
||||||
|
await c.updateComplete;
|
||||||
|
out.spotGone = sr().querySelectorAll('.glowlayer circle').length === out.spots - 1;
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
|
||||||
|
// 4) rgb-лампа красит градиент своим цветом (форсируем rgb у лампы)
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'on', attributes: { ...st0.attributes, rgb_color: [255, 0, 0] } } } };
|
||||||
|
await c.updateComplete;
|
||||||
|
out.gradientColored = [...sr().querySelectorAll('defs radialGradient stop')].some((s2) => s2.getAttribute('stop-color') === 'rgb(255, 0, 0)');
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
|
||||||
|
// 5) пятно обрезано комнатой (есть clipPath)
|
||||||
|
out.clipped = sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]').length > 0;
|
||||||
|
// 6) дверь в соседнюю комнату → в clip добавлен сектор (2 сабпути M)
|
||||||
|
// добавим дверь между r1 и r2 на общей стене x=0.55… найдём общую стену программно:
|
||||||
|
const spm = c._spaceModel();
|
||||||
|
const r1 = spm.rooms.find((r) => r.id === 'r1');
|
||||||
|
const r2 = spm.rooms.find((r) => r.id === 'r2');
|
||||||
|
// возьмём точку на границе r1, ближайшую к центру r2
|
||||||
|
const c2 = c._roomCenter(r2);
|
||||||
|
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||||
|
// общая стена вертикальная — дверь ставим на неё
|
||||||
|
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||||
|
const doorPt = (() => {
|
||||||
|
let best = null, bd = 1e9;
|
||||||
|
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||||||
|
const d2 = Math.hypot(x - c2[0], y - c2[1]);
|
||||||
|
if (d2 < bd) { bd = d2; best = [x, y]; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
})();
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||||
|
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
|
||||||
|
const aspect = c._curSpaceCfg.aspect || 1;
|
||||||
|
const c1 = c._roomCenter(r1);
|
||||||
|
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||||
|
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
|
||||||
|
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
const clips = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path')];
|
||||||
|
out.sectorAdded = clips.some((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length >= 2);
|
||||||
|
// у входной двери (наружу) сектора нет: дверь на внешней стене r1 (x=min)
|
||||||
|
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||||
|
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||||
|
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
const clips2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path')];
|
||||||
|
out.entranceNoSector = clips2.every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1);
|
||||||
|
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
|
||||||
|
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||||||
|
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||||||
|
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 1));
|
||||||
|
await browser.close();
|
||||||
+132
-108
File diff suppressed because one or more lines are too long
Vendored
+132
-108
File diff suppressed because one or more lines are too long
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.35.0 — 2026-07-23 (glow fill: dark house, glowing lamps)
|
||||||
|
- New fill mode **"Light sources"**: the whole house is painted with a single
|
||||||
|
configurable darkness color, and every lit lamp casts a radial pool of light
|
||||||
|
around itself. The pool color comes from the lamp's `rgb_color`, else its
|
||||||
|
color temperature (blackbody conversion), else a configurable default;
|
||||||
|
brightness scales the intensity.
|
||||||
|
- Pools are clipped by the source's room — **plus the sector through each
|
||||||
|
doorway** (rays from the source to the door edges, out to the glow radius),
|
||||||
|
so light spills into neighbouring rooms through doors. Entrance doors (no
|
||||||
|
room behind) spill nothing; windows don't spill. No shadow casting: islands
|
||||||
|
and furniture do not block light (deliberate limitation).
|
||||||
|
- The glow radius is configured in General settings in your HA unit system
|
||||||
|
(meters or feet; stored in cm, default 3 m). The palette gained a "glow"
|
||||||
|
group: house darkness + default light color/intensity.
|
||||||
|
|
||||||
## v1.34.0 — 2026-07-22 (island rooms)
|
## v1.34.0 — 2026-07-22 (island rooms)
|
||||||
- **Nested rooms are now legal**: draw a contour fully inside an existing room
|
- **Nested rooms are now legal**: draw a contour fully inside an existing room
|
||||||
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
|
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
(explicit ripple color still wins); off/white lights unchanged [auto]
|
||||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||||
|
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
|
||||||
|
one uniform darkness color; lit lamps glow with a radial gradient
|
||||||
|
(rgb_color → color temp → default color; brightness scales opacity),
|
||||||
|
clipped by the source's room plus door sectors into NEIGHBOUR rooms
|
||||||
|
(entrance doors leak nothing; windows don't spill); radius set in
|
||||||
|
general settings in HA units (m/ft, stored in cm); no shadow casting —
|
||||||
|
islands don't block light (documented limitation) [auto]
|
||||||
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
|
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
|
||||||
(or around one) saves as a nested room — column in a ring, inner room;
|
(or around one) saves as a nested room — column in a ring, inner room;
|
||||||
the parent's fill renders with an evenodd hole so the ring paints
|
the parent's fill renders with an evenodd hole so the ring paints
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.23.2",
|
"version": "1.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.23.2",
|
"version": "1.34.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3",
|
"lit": "^3.1.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+100
-8
@@ -17,7 +17,7 @@ import {
|
|||||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf,
|
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf,
|
||||||
snapToWall, openingAmount,
|
snapToWall, openingAmount,
|
||||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind,
|
||||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||||
@@ -32,7 +32,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.34.0';
|
const CARD_VERSION = '1.35.0';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -145,7 +145,7 @@ class HouseplanCard extends LitElement {
|
|||||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||||
|
|
||||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||||
private _settingsDialog: { colors: FillColors; busy: boolean } | null = null;
|
private _settingsDialog: { colors: FillColors; glowRadius: number; busy: boolean } | null = null;
|
||||||
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
|
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
|
||||||
private _importQueue: string[] = []; // floor titles still to create
|
private _importQueue: string[] = []; // floor titles still to create
|
||||||
private _importTotal = 0;
|
private _importTotal = 0;
|
||||||
@@ -185,7 +185,7 @@ class HouseplanCard extends LitElement {
|
|||||||
showNames: boolean;
|
showNames: boolean;
|
||||||
roomColor: string;
|
roomColor: string;
|
||||||
roomOpacity: number; // 0..1
|
roomOpacity: number; // 0..1
|
||||||
fillMode: 'none' | 'lqi' | 'light' | 'temp';
|
fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
|
||||||
tempMin: number;
|
tempMin: number;
|
||||||
tempMax: number;
|
tempMax: number;
|
||||||
showLqi: boolean;
|
showLqi: boolean;
|
||||||
@@ -2578,7 +2578,11 @@ class HouseplanCard extends LitElement {
|
|||||||
private _openSettingsDialog = (): void => {
|
private _openSettingsDialog = (): void => {
|
||||||
if (!this._norm) return;
|
if (!this._norm) return;
|
||||||
// deep copy so the dialog edits do not leak into the live palette
|
// deep copy so the dialog edits do not leak into the live palette
|
||||||
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), busy: false };
|
const cm = this._glowRadiusCm;
|
||||||
|
const glowRadius = this._imperial
|
||||||
|
? Math.round((cm / 30.48) * 10) / 10
|
||||||
|
: Math.round(cm) / 100;
|
||||||
|
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), glowRadius, busy: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
|
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
|
||||||
@@ -2596,6 +2600,9 @@ class HouseplanCard extends LitElement {
|
|||||||
const settings: any = { ...cfg.settings };
|
const settings: any = { ...cfg.settings };
|
||||||
if (isDefault) delete settings.fill_colors;
|
if (isDefault) delete settings.fill_colors;
|
||||||
else settings.fill_colors = d.colors;
|
else settings.fill_colors = d.colors;
|
||||||
|
const cm = this._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100;
|
||||||
|
if (Number.isFinite(cm) && cm > 0 && Math.round(cm) !== 300) settings.glow_radius_cm = Math.round(cm);
|
||||||
|
else delete settings.glow_radius_cm;
|
||||||
this._serverCfg = { ...cfg, settings };
|
this._serverCfg = { ...cfg, settings };
|
||||||
await this._saveConfigNow();
|
await this._saveConfigNow();
|
||||||
this._settingsDialog = null;
|
this._settingsDialog = null;
|
||||||
@@ -2620,6 +2627,73 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Glow radius: stored in cm (config.settings.glow_radius_cm), default 3 m. */
|
||||||
|
private get _glowRadiusCm(): number {
|
||||||
|
const v = Number((this._settings as any).glow_radius_cm);
|
||||||
|
return Number.isFinite(v) && v > 0 ? v : 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _imperial(): boolean {
|
||||||
|
return this.hass?.config?.unit_system?.length === 'mi';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Light pools of the current space: dark house, glowing sources. */
|
||||||
|
private _renderGlowLayer(space: SpaceModel): TemplateResult {
|
||||||
|
const colors = this._fillColors;
|
||||||
|
const R = (this._glowRadiusCm / this._cellCm) * this._gridPitch;
|
||||||
|
const g = this._gridPitch;
|
||||||
|
const polys = space.rooms
|
||||||
|
.map((r) => ({ r, poly: roomPoly(r) }))
|
||||||
|
.filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly);
|
||||||
|
const doors = this._openingsR.filter((o) => o.type === 'door');
|
||||||
|
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string | null }[] = [];
|
||||||
|
for (const d of this._devices) {
|
||||||
|
if (d.space !== space.id) continue;
|
||||||
|
const lightEid = d.entities.find(
|
||||||
|
(e) => e.startsWith('light.') && this.hass.states[e]?.state === 'on',
|
||||||
|
);
|
||||||
|
if (!lightEid) continue;
|
||||||
|
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
|
||||||
|
if (!glow) continue;
|
||||||
|
const pos = this._pos(d);
|
||||||
|
// innermost room under the source (islands win — reverse order)
|
||||||
|
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
|
||||||
|
let clip: string | null = null;
|
||||||
|
if (home) {
|
||||||
|
const shapes: string[] = ['M ' + home.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z'];
|
||||||
|
// doorways of this room spill light into neighbouring rooms only
|
||||||
|
for (const o of doors) {
|
||||||
|
const near = closestPointOnBoundary([o.rx, o.ry], home.poly);
|
||||||
|
if (!near || Math.hypot(near[0] - o.rx, near[1] - o.ry) > g * 0.75) continue;
|
||||||
|
const rad = (o.angle * Math.PI) / 180;
|
||||||
|
const dx = (Math.cos(rad) * o.rlen) / 2;
|
||||||
|
const dy = (Math.sin(rad) * o.rlen) / 2;
|
||||||
|
const others = polys.filter((x) => x !== home).map((x) => x.poly);
|
||||||
|
if (!hasRoomBehind([o.rx, o.ry], o.angle, [pos.x, pos.y], others, g * 0.6)) continue;
|
||||||
|
const sector = doorSector([pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy], R);
|
||||||
|
if (sector) shapes.push('M ' + sector.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z');
|
||||||
|
}
|
||||||
|
clip = shapes.join(' ');
|
||||||
|
}
|
||||||
|
spots.push({ pos, c: glow.c, alpha: colors.glow_light.a * glow.bri, clip });
|
||||||
|
}
|
||||||
|
if (!spots.length) return svg`` as unknown as TemplateResult;
|
||||||
|
return svg`<defs>
|
||||||
|
${spots.map((sp, i) => svg`
|
||||||
|
<radialGradient id="hp-glow-${i}">
|
||||||
|
<stop offset="0%" stop-color="${sp.c}" stop-opacity="${sp.alpha.toFixed(3)}"></stop>
|
||||||
|
<stop offset="55%" stop-color="${sp.c}" stop-opacity="${(sp.alpha * 0.45).toFixed(3)}"></stop>
|
||||||
|
<stop offset="100%" stop-color="${sp.c}" stop-opacity="0"></stop>
|
||||||
|
</radialGradient>
|
||||||
|
${sp.clip ? svg`<clipPath id="hp-glowclip-${i}"><path d="${sp.clip}"></path></clipPath>` : nothing}`)}
|
||||||
|
</defs>
|
||||||
|
<g class="glowlayer">
|
||||||
|
${spots.map((sp, i) => svg`<circle cx="${sp.pos.x}" cy="${sp.pos.y}" r="${R}"
|
||||||
|
fill="url(#hp-glow-${i})" ${''}
|
||||||
|
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
|
||||||
|
</g>` as unknown as TemplateResult;
|
||||||
|
}
|
||||||
|
|
||||||
private _renderSettingsDialog(): TemplateResult {
|
private _renderSettingsDialog(): TemplateResult {
|
||||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
|
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
@@ -2637,10 +2711,24 @@ class HouseplanCard extends LitElement {
|
|||||||
<label class="dispsection">${this._t('gs.lqi_group')}</label>
|
<label class="dispsection">${this._t('gs.lqi_group')}</label>
|
||||||
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
|
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
|
||||||
${this._renderColorRow('lqi_high', 'gs.lqi_high')}
|
${this._renderColorRow('lqi_high', 'gs.lqi_high')}
|
||||||
|
<label class="dispsection">${this._t('gs.glow_group')}</label>
|
||||||
|
${this._renderColorRow('glow_base', 'gs.glow_base')}
|
||||||
|
${this._renderColorRow('glow_light', 'gs.glow_light')}
|
||||||
|
<div class="colorrow gsrow">
|
||||||
|
<span class="gsl">${this._t('gs.glow_radius')}</span>
|
||||||
|
<input type="number" class="tempin" min="0.5" step="0.5"
|
||||||
|
.value=${String(this._settingsDialog!.glowRadius)}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||||
|
if (Number.isFinite(v) && v > 0)
|
||||||
|
this._settingsDialog = { ...this._settingsDialog!, glowRadius: v };
|
||||||
|
}} />
|
||||||
|
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="btn ghost" @click=${() =>
|
<button class="btn ghost" @click=${() =>
|
||||||
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)) })}>
|
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3 })}>
|
||||||
${this._t('gs.reset')}
|
${this._t('gs.reset')}
|
||||||
</button>
|
</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
@@ -2886,7 +2974,10 @@ class HouseplanCard extends LitElement {
|
|||||||
const st: string[] = [];
|
const st: string[] = [];
|
||||||
// keep the stroke colour even when borders are hidden, so hover can reveal it
|
// keep the stroke colour even when borders are hidden, so hover can reveal it
|
||||||
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
|
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
|
||||||
const fillC = r.area
|
const fillC = disp.fill === 'glow'
|
||||||
|
// glow: uniform darkness over EVERY room (area or not, lit or not)
|
||||||
|
? this._fillColors.glow_base
|
||||||
|
: r.area
|
||||||
? roomFillStyle(
|
? roomFillStyle(
|
||||||
disp.fill,
|
disp.fill,
|
||||||
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
|
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
|
||||||
@@ -2931,6 +3022,7 @@ class HouseplanCard extends LitElement {
|
|||||||
@mouseleave=${() => (this._tip = null)}></rect>`;
|
@mouseleave=${() => (this._tip = null)}></rect>`;
|
||||||
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||||
})}
|
})}
|
||||||
|
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||||
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||||
${this._renderOpenings(disp)}
|
${this._renderOpenings(disp)}
|
||||||
</svg>
|
</svg>
|
||||||
@@ -3850,7 +3942,7 @@ class HouseplanCard extends LitElement {
|
|||||||
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
|
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<label>${this._t('space.fill_label')}</label>
|
<label>${this._t('space.fill_label')}</label>
|
||||||
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']].map(
|
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp'], ['glow', 'fill.glow']].map(
|
||||||
([v, k]) => html`<label class="srcrow">
|
([v, k]) => html`<label class="srcrow">
|
||||||
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
|
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
|
||||||
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
|
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
|
||||||
|
|||||||
+8
-1
@@ -280,5 +280,12 @@
|
|||||||
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
||||||
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
||||||
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
||||||
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks"
|
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks",
|
||||||
|
"fill.glow": "Light sources (dark house, glowing lamps)",
|
||||||
|
"gs.glow_group": "Light-sources fill",
|
||||||
|
"gs.glow_base": "House darkness",
|
||||||
|
"gs.glow_light": "Default light color / intensity",
|
||||||
|
"gs.glow_radius": "Glow radius",
|
||||||
|
"gs.unit_m": "m",
|
||||||
|
"gs.unit_ft": "ft"
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -280,5 +280,12 @@
|
|||||||
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
||||||
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
||||||
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
||||||
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики"
|
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
|
||||||
|
"fill.glow": "Свет по источникам (тёмный дом, пятна света)",
|
||||||
|
"gs.glow_group": "Заливка «Свет по источникам»",
|
||||||
|
"gs.glow_base": "Темнота дома",
|
||||||
|
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
|
||||||
|
"gs.glow_radius": "Радиус свечения",
|
||||||
|
"gs.unit_m": "м",
|
||||||
|
"gs.unit_ft": "фут"
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-2
@@ -535,7 +535,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
|
|||||||
|
|
||||||
// ---------------- room fills & colors ----------------
|
// ---------------- room fills & colors ----------------
|
||||||
|
|
||||||
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
|
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp' | 'glow';
|
||||||
|
|
||||||
/** Per-space display settings with their defaults resolved. */
|
/** Per-space display settings with their defaults resolved. */
|
||||||
export interface SpaceDisplay {
|
export interface SpaceDisplay {
|
||||||
@@ -569,7 +569,7 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
|||||||
showNames: s.show_names ?? noPlan,
|
showNames: s.show_names ?? noPlan,
|
||||||
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
|
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
|
||||||
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
|
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
|
||||||
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
fill: ['lqi', 'light', 'temp', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
||||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||||
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
||||||
@@ -598,6 +598,9 @@ export interface FillColors {
|
|||||||
temp_hot: FillColorEntry;
|
temp_hot: FillColorEntry;
|
||||||
lqi_low: FillColorEntry;
|
lqi_low: FillColorEntry;
|
||||||
lqi_high: FillColorEntry;
|
lqi_high: FillColorEntry;
|
||||||
|
/** Glow mode: uniform "darkness" over every room + default light color. */
|
||||||
|
glow_base: FillColorEntry;
|
||||||
|
glow_light: FillColorEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FILL_COLORS: FillColors = {
|
export const DEFAULT_FILL_COLORS: FillColors = {
|
||||||
@@ -609,6 +612,8 @@ export const DEFAULT_FILL_COLORS: FillColors = {
|
|||||||
temp_hot: { c: '#ffd45c', a: 0.18 },
|
temp_hot: { c: '#ffd45c', a: 0.18 },
|
||||||
lqi_low: { c: '#f25a4a', a: 0.18 },
|
lqi_low: { c: '#f25a4a', a: 0.18 },
|
||||||
lqi_high: { c: '#4bd28f', a: 0.18 },
|
lqi_high: { c: '#4bd28f', a: 0.18 },
|
||||||
|
glow_base: { c: '#0d1b2a', a: 0.5 },
|
||||||
|
glow_light: { c: '#ffd9a0', a: 0.85 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
||||||
@@ -752,6 +757,87 @@ export function lightColorOf(state: any): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- glow fill (light sources) ----------------
|
||||||
|
|
||||||
|
/** Blackbody color temperature → RGB (Tanner Helland approximation). */
|
||||||
|
export function kelvinToRgb(kelvin: number): [number, number, number] {
|
||||||
|
const t = Math.min(40000, Math.max(1000, kelvin)) / 100;
|
||||||
|
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
|
||||||
|
const g = t <= 66
|
||||||
|
? 99.4708025861 * Math.log(t) - 161.1195681661
|
||||||
|
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
|
||||||
|
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
||||||
|
const cl = (v: number) => Math.round(Math.min(255, Math.max(0, v)));
|
||||||
|
return [cl(r), cl(g), cl(b)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color and relative brightness of a light's glow: rgb_color as is, else the
|
||||||
|
* color temperature via blackbody, else the configured fallback. Off → null.
|
||||||
|
*/
|
||||||
|
export function glowColorOf(state: any, fallback: string): { c: string; bri: number } | null {
|
||||||
|
if (!state || state.state !== 'on') return null;
|
||||||
|
const a = state.attributes || {};
|
||||||
|
const briRaw = Number(a.brightness);
|
||||||
|
const bri = Number.isFinite(briRaw) && briRaw > 0 ? Math.max(0.15, Math.min(1, briRaw / 255)) : 1;
|
||||||
|
const rgb = a.rgb_color;
|
||||||
|
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v)))
|
||||||
|
return { c: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`, bri };
|
||||||
|
const kelvin = Number(a.color_temp_kelvin) || (Number(a.color_temp) > 0 ? 1e6 / Number(a.color_temp) : NaN);
|
||||||
|
if (Number.isFinite(kelvin) && kelvin > 0) {
|
||||||
|
const [r, g, b] = kelvinToRgb(kelvin);
|
||||||
|
return { c: `rgb(${r}, ${g}, ${b})`, bri };
|
||||||
|
}
|
||||||
|
return { c: fallback, bri };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light spilling through a doorway: the sector of the glow circle between the
|
||||||
|
* rays source→A and source→B (door edge points), out to radius r. This part of
|
||||||
|
* the circle is intentionally NOT clipped by the room (owner's spec: no shadow
|
||||||
|
* casting — just the unclipped sector). Null when the door is out of reach or
|
||||||
|
* the source sits on a door edge; the sweep is clamped to maxDeg.
|
||||||
|
*/
|
||||||
|
export function doorSector(
|
||||||
|
src: number[], a: number[], b: number[], r: number, maxDeg = 170,
|
||||||
|
): number[][] | null {
|
||||||
|
const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
|
||||||
|
const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
|
||||||
|
if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
|
||||||
|
let aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
|
||||||
|
let sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
|
||||||
|
while (sweep > Math.PI) sweep -= 2 * Math.PI;
|
||||||
|
while (sweep < -Math.PI) sweep += 2 * Math.PI;
|
||||||
|
const max = (maxDeg * Math.PI) / 180;
|
||||||
|
if (Math.abs(sweep) > max) {
|
||||||
|
const mid = aa + sweep / 2;
|
||||||
|
sweep = max * Math.sign(sweep);
|
||||||
|
aa = mid - sweep / 2;
|
||||||
|
}
|
||||||
|
const steps = 8;
|
||||||
|
const pts: number[][] = [[src[0], src[1]]];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const ang = aa + (sweep * i) / steps;
|
||||||
|
pts.push([src[0] + Math.cos(ang) * r, src[1] + Math.sin(ang) * r]);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is there a room on the far side of an opening (relative to the light source)?
|
||||||
|
* Entrance doors lead outside — light must not spill there.
|
||||||
|
*/
|
||||||
|
export function hasRoomBehind(
|
||||||
|
center: number[], angleDeg: number, src: number[], polys: number[][][], probe: number,
|
||||||
|
): boolean {
|
||||||
|
const rad = (angleDeg * Math.PI) / 180;
|
||||||
|
const n = [-Math.sin(rad), Math.cos(rad)];
|
||||||
|
const toSrc = (src[0] - center[0]) * n[0] + (src[1] - center[1]) * n[1];
|
||||||
|
const sgn = toSrc > 0 ? -1 : 1;
|
||||||
|
const p = [center[0] + n[0] * probe * sgn, center[1] + n[1] * probe * sgn];
|
||||||
|
return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
/** Device classes whose active state is an emergency, not a status. */
|
/** Device classes whose active state is an emergency, not a status. */
|
||||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||||
splitRoomPath, polyContainsPoly, islandsOf,
|
splitRoomPath, polyContainsPoly, islandsOf,
|
||||||
|
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
@@ -598,3 +599,53 @@ test('splitRoomPath rejects bad paths', () => {
|
|||||||
// менее двух точек
|
// менее двух точек
|
||||||
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
|
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('kelvinToRgb: warm is orange-ish, cool is blue-ish white', () => {
|
||||||
|
const warm = kelvinToRgb(2700);
|
||||||
|
assert.equal(warm[0], 255);
|
||||||
|
assert.ok(warm[2] < 200 && warm[1] < 230);
|
||||||
|
const cool = kelvinToRgb(6600);
|
||||||
|
assert.ok(cool[0] > 245 && cool[1] > 240 && cool[2] > 245);
|
||||||
|
// clamps
|
||||||
|
assert.ok(kelvinToRgb(100)[0] === 255);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('glowColorOf: rgb wins, then color temp, then fallback; off = null', () => {
|
||||||
|
assert.equal(glowColorOf({ state: 'off', attributes: {} }, '#fff'), null);
|
||||||
|
assert.equal(glowColorOf(null, '#fff'), null);
|
||||||
|
const rgb = glowColorOf({ state: 'on', attributes: { rgb_color: [10, 20, 30], brightness: 128 } }, '#fff');
|
||||||
|
assert.equal(rgb.c, 'rgb(10, 20, 30)');
|
||||||
|
assert.ok(Math.abs(rgb.bri - 0.5) < 0.01);
|
||||||
|
const ct = glowColorOf({ state: 'on', attributes: { color_temp_kelvin: 2700 } }, '#fff');
|
||||||
|
assert.ok(ct.c.startsWith('rgb(255'));
|
||||||
|
const mireds = glowColorOf({ state: 'on', attributes: { color_temp: 370 } }, '#fff'); // ~2700K
|
||||||
|
assert.ok(mireds.c.startsWith('rgb(255'));
|
||||||
|
const fb = glowColorOf({ state: 'on', attributes: {} }, '#abcdef');
|
||||||
|
assert.equal(fb.c, '#abcdef');
|
||||||
|
assert.equal(fb.bri, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doorSector: sector through a door, clamped and guarded', () => {
|
||||||
|
const s = [0, 0];
|
||||||
|
const sec = doorSector(s, [10, -2], [10, 2], 50);
|
||||||
|
assert.ok(sec && sec.length === 10 + 0); // вершина + 9 точек дуги
|
||||||
|
assert.deepEqual(sec[0], [0, 0]);
|
||||||
|
for (const p of sec.slice(1)) {
|
||||||
|
const d = Math.hypot(p[0], p[1]);
|
||||||
|
assert.ok(Math.abs(d - 50) < 1e-6);
|
||||||
|
assert.ok(p[0] > 0); // сектор смотрит в сторону двери
|
||||||
|
}
|
||||||
|
// дверь за радиусом
|
||||||
|
assert.equal(doorSector(s, [60, -2], [60, 2], 50), null);
|
||||||
|
// источник на краю двери
|
||||||
|
assert.equal(doorSector(s, [0, 0], [10, 2], 50), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasRoomBehind: neighbour room yes, street no', () => {
|
||||||
|
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
|
||||||
|
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
|
||||||
|
assert.ok(hasRoomBehind([10, 0], 90, [5, 0], [neighbour], 1));
|
||||||
|
// за дверью пусто
|
||||||
|
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [], 1));
|
||||||
|
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [[[30, 30], [40, 30], [40, 40], [30, 40]]], 1));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user