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:
Matysh
2026-07-23 12:51:48 +03:00
parent 1e20279adf
commit 7eaf513c9e
16 changed files with 765 additions and 342 deletions
+100 -8
View File
@@ -17,7 +17,7 @@ import {
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf,
snapToWall, openingAmount,
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,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
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 _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 _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0;
@@ -185,7 +185,7 @@ class HouseplanCard extends LitElement {
showNames: boolean;
roomColor: string;
roomOpacity: number; // 0..1
fillMode: 'none' | 'lqi' | 'light' | 'temp';
fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
tempMin: number;
tempMax: number;
showLqi: boolean;
@@ -2578,7 +2578,11 @@ class HouseplanCard extends LitElement {
private _openSettingsDialog = (): void => {
if (!this._norm) return;
// 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 {
@@ -2596,6 +2600,9 @@ class HouseplanCard extends LitElement {
const settings: any = { ...cfg.settings };
if (isDefault) delete settings.fill_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 };
await this._saveConfigNow();
this._settingsDialog = null;
@@ -2620,6 +2627,73 @@ class HouseplanCard extends LitElement {
</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 {
return html`<div class="menuwrap dialogwrap" @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>
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
${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 class="row">
<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')}
</button>
<span class="spacer"></span>
@@ -2886,7 +2974,10 @@ class HouseplanCard extends LitElement {
const st: string[] = [];
// 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}`);
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(
disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
@@ -2931,6 +3022,7 @@ class HouseplanCard extends LitElement {
@mouseleave=${() => (this._tip = null)}></rect>`;
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._renderOpenings(disp)}
</svg>
@@ -3850,7 +3942,7 @@ class HouseplanCard extends LitElement {
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div>
<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">
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
+8 -1
View File
@@ -280,5 +280,12 @@
"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.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
View File
@@ -280,5 +280,12 @@
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"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
View File
@@ -535,7 +535,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
// ---------------- 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. */
export interface SpaceDisplay {
@@ -569,7 +569,7 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
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,
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,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
@@ -598,6 +598,9 @@ export interface FillColors {
temp_hot: FillColorEntry;
lqi_low: 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 = {
@@ -609,6 +612,8 @@ export const DEFAULT_FILL_COLORS: FillColors = {
temp_hot: { c: '#ffd45c', a: 0.18 },
lqi_low: { c: '#f25a4a', 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;
@@ -752,6 +757,87 @@ export function lightColorOf(state: any): string | 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 sourceA and sourceB (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. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);