mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28: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:
+100
-8
@@ -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
@@ -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
@@ -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
@@ -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 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. */
|
||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user