Release v1.61.0-beta.2 candidate

This commit is contained in:
Matysh
2026-08-10 00:24:42 +03:00
parent 112c260314
commit fb382bfa11
49 changed files with 6342 additions and 1509 deletions
+67
View File
@@ -0,0 +1,67 @@
/** Runtime proof that this document can rasterize isolated SVG screen blend. */
const documentProbes = new WeakMap<Document, Promise<boolean>>();
export type Rgb = readonly [number, number, number];
/** CSS screen formula in 8-bit sRGB, rounded exactly like a pixel sample. */
export function screenRgb(a: Rgb, b: Rgb): [number, number, number] {
return [0, 1, 2].map((index) => Math.round(
255 - ((255 - a[index]) * (255 - b[index])) / 255,
)) as [number, number, number];
}
const closePixel = (actual: Uint8ClampedArray, expected: Rgb, offset: number): boolean => (
actual[offset + 3] === 255
&& expected.every((channel, index) => Math.abs(actual[offset + index] - channel) <= 2)
);
async function renderProbe(doc: Document): Promise<boolean> {
const win = doc.defaultView;
if (!win || !win.CSS?.supports?.('mix-blend-mode', 'screen')) return false;
const sourceA: Rgb = [128, 32, 16];
const sourceB: Rgb = [16, 64, 128];
const background: Rgb = [9, 19, 29];
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="4" height="1" viewBox="0 0 4 1">
<rect width="4" height="1" fill="rgb(${background.join(',')})"/>
<g style="isolation:isolate">
<rect x="0" width="2" height="1" fill="rgb(${sourceA.join(',')})"/>
<rect x="1" width="2" height="1" fill="rgb(${sourceB.join(',')})" style="mix-blend-mode:screen"/>
</g>
</svg>`;
const url = win.URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const node = new win.Image();
const timer = win.setTimeout(() => reject(new Error('SVG blend probe timeout')), 1000);
node.onload = () => { win.clearTimeout(timer); resolve(node); };
node.onerror = () => { win.clearTimeout(timer); reject(new Error('SVG blend probe failed')); };
node.src = url;
});
const canvas = doc.createElement('canvas');
canvas.width = 4;
canvas.height = 1;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) return false;
context.drawImage(image, 0, 0, 4, 1);
const pixels = context.getImageData(0, 0, 4, 1).data;
return closePixel(pixels, sourceA, 0)
&& closePixel(pixels, screenRgb(sourceA, sourceB), 4)
&& closePixel(pixels, sourceB, 8)
&& closePixel(pixels, background, 12);
} catch {
return false;
} finally {
win.URL.revokeObjectURL(url);
}
}
/** One cached Promise per real Document; errors are a normal false fallback. */
export function svgScreenBlendSupported(doc: Document): Promise<boolean> {
const cached = documentProbes.get(doc);
if (cached) return cached;
const probe = renderProbe(doc).catch(() => false);
documentProbes.set(doc, probe);
return probe;
}
+250 -30
View File
@@ -17,7 +17,7 @@ import {
import {
lqiColor, snapToGrid, snapSegment45, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, roomGlowOf, contentUrl,
snapToWall, snapPointAlongPoly, openingAmount, openingShoulders, interiorPoint,
poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
@@ -118,6 +118,7 @@ import {
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import { langOf, t, type I18nKey } from './i18n';
import { CommandStack } from './command-stack';
import { svgScreenBlendSupported } from './glow-blend';
import {
combineVisualSamples, edgeActivity, entityVisualSample, entityVisualSamplesForDevice,
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
@@ -144,7 +145,7 @@ import {
import { renderOpeningTunnelFills } from './render/opening-tunnels';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.61.0-beta.1';
const CARD_VERSION = '1.61.0-beta.2';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -841,6 +842,8 @@ class HouseplanCard extends LitElement {
floor: number[][]; geom: any; path: string; area: number;
}>();
private _glowClipCache = new Map<string, string[] | null>();
/** Pending/false uses the exact historical normal-layer fallback. */
private _glowScreenBlend = false;
private _duplicateColumnId: string | null = null;
private _duplicateColumnTimer = 0;
// room resize tool (docs/RESIZE.md): selection and an immutable live preview
@@ -1069,7 +1072,8 @@ class HouseplanCard extends LitElement {
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';
fillMode: 'none' | 'lqi' | 'light' | 'temp';
glowEnabled: boolean;
tempMin: number;
tempMax: number;
showLqi: boolean;
@@ -1290,6 +1294,11 @@ class HouseplanCard extends LitElement {
public connectedCallback(): void {
document.addEventListener('visibilitychange', this._vacVisHandler);
super.connectedCallback();
svgScreenBlendSupported(this.ownerDocument).then((supported) => {
if (!supported || this._glowScreenBlend) return;
this._glowScreenBlend = true;
if (this.isConnected) this.requestUpdate();
});
if (this.hass) this._ensureHaRegistryAuthority();
window.addEventListener('keydown', this._keyHandler);
// signatures expire (24 h); refresh well before that on long-lived screens
@@ -1440,7 +1449,7 @@ class HouseplanCard extends LitElement {
if (this._vacCalConfirm) { this._vacCalConfirm = null; return; }
if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; }
if (this._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._infoCard = null; return; }
if (this._infoCard) { this._closeInfoCard(); return; }
if (this._rulesDialog) { this._rulesDialog = null; return; }
if (this._alignDialog) { this._alignDialog = null; return; }
if (this._settingsDialog) { this._settingsDialog = null; return; }
@@ -2848,7 +2857,7 @@ class HouseplanCard extends LitElement {
if (disabledNow) {
if (this._infoCard?.bindingStatus?.kind === 'ha_disabled'
|| this._devices.find((d) => d.id === this._infoCard?.id)?.bindingStatus?.kind === 'ha_disabled') {
this._infoCard = null;
this._closeInfoCard();
}
if (this._drag && this._devices.find((d) => d.id === this._drag!.id)?.bindingStatus?.kind === 'ha_disabled') {
this._drag = null;
@@ -3168,6 +3177,38 @@ class HouseplanCard extends LitElement {
fireEvent(this, 'hass-more-info', { entityId });
}
/**
* A modal opened while a view pointer is still down interrupts the stage's
* normal pointerup lifecycle. Drop every navigation anchor immediately and
* release capture defensively, even though the current view path does not
* normally request it. This is intentionally separate from editor geometry.
*/
private _interruptViewGesture(pointerId?: number, source?: Element | null): void {
clearTimeout(this._holdTimer);
clearTimeout(this._kioskHoldTimer);
if (pointerId !== undefined) {
for (const element of [source, this._stageEl]) {
try {
if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
} catch {
/* Pointer ownership may already have moved to the modal. */
}
}
}
this._pointers.clear();
this._panStart = null;
this._panLock = null;
this._pinchStart = null;
this._swipeStart = null;
}
/** Close the device card and make stale long-press state non-observable. */
private _closeInfoCard(): void {
this._interruptViewGesture();
this._holdFired = false;
this._infoCard = null;
}
/** Right click in VIEW mode always opens HA's more-info (owner's decision). */
private _ctxDevice(ev: MouseEvent, d: DevItem): void {
if (this._mode !== 'view') return; // editors keep the native context menu
@@ -3974,8 +4015,11 @@ class HouseplanCard extends LitElement {
// long-press timer runs (cancelled by stage movement)
this._holdFired = false;
clearTimeout(this._holdTimer);
const pointerId = ev.pointerId;
const source = ev.currentTarget as Element | null;
this._holdTimer = window.setTimeout(() => {
this._holdFired = true;
this._interruptViewGesture(pointerId, source);
this._infoCard = d;
}, 600);
return;
@@ -8453,7 +8497,11 @@ class HouseplanCard extends LitElement {
const byRoom = new Map<RoomCfg, ResolvedRoomFill | null>();
const byId = new Map<string, ResolvedRoomFill | null>();
for (const room of space.rooms) {
const mode = roomFillModeOf(disp.fill, room);
// The dimmed plan behind the room dialog is a real preview, not a second
// renderer. Feed its pending selection through this same frame resolver.
const mode = this._roomDialog && room.id === this._roomEditId
? (this._roomFill || disp.fill)
: roomFillModeOf(disp.fill, room);
const resolved = resolveEffectiveRoomFill(
mode,
mode === 'lqi' && room.area ? this._roomLqi(room.area) : null,
@@ -8471,6 +8519,32 @@ class HouseplanCard extends LitElement {
return { byRoom, byId };
}
/** Pending space-dialog values projected through the production resolver. */
private _spaceDisplayForRender(): SpaceDisplay {
const current = spaceDisplayOf(this._curSpaceCfg);
const dialog = this._spaceDialog;
if (!dialog || dialog.mode !== 'edit' || dialog.spaceId !== this._space) return current;
return {
...current,
showBorders: dialog.showBorders,
showNames: dialog.showNames,
hideDecor: dialog.hideDecor,
hideOpenings: dialog.hideOpenings,
color: dialog.roomColor,
opacity: dialog.roomOpacity,
fill: dialog.fillMode,
glow: dialog.glowEnabled,
tempMin: dialog.tempMin,
tempMax: dialog.tempMax,
showLqi: dialog.showLqi,
cardFontScale: dialog.cardFontScale,
labelTemp: dialog.labelTemp,
labelHum: dialog.labelHum,
labelLqi: dialog.labelLqi,
labelLight: dialog.labelLight,
};
}
/** Atomic wall association shared by symbols, wall cuts and tunnel geometry. */
private _openingWallIndexFor(space: SpaceModel, openCuts: number[][]): {
key: string; value: OpeningWallIndex;
@@ -8507,11 +8581,17 @@ class HouseplanCard extends LitElement {
private _renderOpeningTunnelFills(
space: SpaceModel,
roomFills: RoomFillFrame,
layer: 'data' | 'glow-base' = 'data',
): TemplateResult {
// Plan mode replaces live fills with its blue editing wash. A coloured
// tunnel there would no longer repeat the room and could obstruct handles.
if (this._markup || !this._spaceWalls.length || !this._openingsR.length)
return svg`` as unknown as TemplateResult;
// Do not leave an empty presentation layer behind when Glow is disabled
// for every room. Besides keeping the DOM contract explicit, this avoids
// needless opening-index work on plans that do not use the overlay.
if (layer === 'glow-base' && ![...roomFills.byRoom.values()].some(Boolean))
return svg`` as unknown as TemplateResult;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const geometryInputs = this._openingsR.map((opening) => ({
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
@@ -8530,9 +8610,85 @@ class HouseplanCard extends LitElement {
openings: this._openingsR,
geometries: this._openingTunnelCache.value,
fillsByRoomId: roomFills.byId,
idPrefix: `${space.id}-${layer}`,
groupClass: layer === 'data' ? 'opening-tunnels' : 'opening-tunnels glow-base-tunnels',
dataLayer: layer,
});
}
/** Frame-local Glow-base projection, deliberately independent from data fill. */
private _resolvedGlowBase(space: SpaceModel, disp: SpaceDisplay): RoomFillFrame {
const byRoom = new Map<RoomCfg, ResolvedRoomFill | null>();
const byId = new Map<string, ResolvedRoomFill | null>();
const base = this._fillColors.glow_base;
for (const room of space.rooms) {
const resolved: ResolvedRoomFill | null = roomGlowOf(disp.glow, room)
? { color: base.c, opacity: base.a, mode: 'glow' }
: null;
byRoom.set(room, resolved);
if (room.id) byId.set(room.id, resolved);
}
return { byRoom, byId };
}
/**
* Paint the same clean-floor geometry as the interactive room layer, but as
* a pointer-transparent source-over overlay above the resolved data fill.
*/
private _renderGlowBaseRooms(space: SpaceModel, frame: RoomFillFrame): TemplateResult {
if (this._markup || ![...frame.byRoom.values()].some(Boolean))
return svg`` as unknown as TemplateResult;
const polys = new Map<RoomCfg, number[][] | null>(
space.rooms.map((room) => [room, roomPoly(room)]),
);
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
const pathD = (points: number[][]) =>
'M ' + points.map((point) => point[0] + ' ' + point[1]).join(' L ') + ' Z';
const shapes = space.rooms.map((room) => {
const fill = frame.byRoom.get(room) || null;
const ownPoly = polys.get(room) || null;
if (!fill || !ownPoly) return nothing;
const floor = this._spaceWalls.length && room.id
? (innerContourForRoom(
space.rooms, room.id, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
) || ownPoly)
: ownPoly;
const otherPolys = space.rooms
.filter((other) => other !== room)
.map((other) => polys.get(other))
.filter((poly): poly is number[][] => !!poly);
const holes = islandsOf(floor, otherPolys);
const cleanPath = this._cleanFloor(room, floor, space).path;
if (cleanPath || holes.length) {
return svg`<path class="glow-base" data-room-id=${room.id || nothing}
d="${[cleanPath || pathD(floor), ...holes.map(pathD)].join(' ')}"
fill=${fill.color} fill-opacity=${fill.opacity} fill-rule="evenodd"
pointer-events="none"></path>`;
}
if (room.poly || floor !== ownPoly) {
return svg`<polygon class="glow-base" data-room-id=${room.id || nothing}
points="${floor.map((point) => point.join(',')).join(' ')}"
fill=${fill.color} fill-opacity=${fill.opacity} pointer-events="none"></polygon>`;
}
return svg`<rect class="glow-base" data-room-id=${room.id || nothing}
x=${room.x} y=${room.y} width=${room.w} height=${room.h}
rx=${Math.min(room.w!, room.h!) * 0.03}
fill=${fill.color} fill-opacity=${fill.opacity} pointer-events="none"></rect>`;
});
return svg`<g class="glow-base-layer" aria-hidden="true" pointer-events="none">${shapes}</g>` as unknown as TemplateResult;
}
private _renderSvgRoomLabels(space: SpaceModel, disp: SpaceDisplay): TemplateResult {
if (space.bg || disp.showNames || this._markup) return svg`` as unknown as TemplateResult;
return svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {
const center = this._roomCenter(room);
return svg`<text class="rlabel" data-hp="room-label"
data-id=${room.id || nothing} data-area=${room.area || nothing}
x=${center[0]} y=${center[1]}>${room.name}</text>`;
})}</g>` as unknown as TemplateResult;
}
private _renderWallBodies(disp: SpaceDisplay): TemplateResult {
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
return svg`` as unknown as TemplateResult;
@@ -9931,7 +10087,7 @@ class HouseplanCard extends LitElement {
.catch(() => undefined);
}
this._markerDialog = null;
if (this._infoCard?.id === d.id) this._infoCard = null;
if (this._infoCard?.id === d.id) this._closeInfoCard();
if (this._selId === d.id) this._selId = null;
if (this._drag?.id === d.id) this._drag = null;
this._regSignature = '';
@@ -9964,7 +10120,8 @@ class HouseplanCard extends LitElement {
source: sp.plan_url ? 'file' : 'draw',
showBorders: disp.showBorders, showNames: disp.showNames,
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill as 'none' | 'lqi' | 'light' | 'temp',
glowEnabled: disp.glow,
bgColor: disp.bgColor,
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null,
northDeg: northDegOf({}, sp.settings),
@@ -9983,7 +10140,8 @@ class HouseplanCard extends LitElement {
source: 'file',
showBorders: false, showNames: false,
hideDecor: false, hideOpenings: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
glowEnabled: true,
bgColor: null,
bgMode: null, northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
@@ -10228,6 +10386,10 @@ class HouseplanCard extends LitElement {
north_deg: d.northDeg ?? undefined,
sun_rays: d.sunRays ?? undefined,
fill_mode: d.fillMode,
// Always materialize the independent value. In particular, editing a
// legacy fill_mode:'glow' space preserves its appearance atomically
// while replacing the old token with the selected data fill.
glow_enabled: d.glowEnabled,
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,
show_lqi: d.showLqi,
@@ -10348,7 +10510,8 @@ class HouseplanCard extends LitElement {
source: 'file',
showBorders: false, showNames: false,
hideDecor: false, hideOpenings: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
glowEnabled: true,
bgColor: null,
bgMode: null, northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
@@ -10949,13 +11112,15 @@ class HouseplanCard extends LitElement {
}
/** Light pools of the current space: dark house, glowing sources. */
private _renderGlowLayer(space: SpaceModel): TemplateResult {
private _renderGlowLayer(space: SpaceModel, disp: SpaceDisplay): TemplateResult {
const colors = this._fillColors;
const defaultR = (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 enabled = polys.filter(({ r }) => roomGlowOf(disp.glow, r));
if (!enabled.length) return svg`` as unknown as TemplateResult;
// Gates are door-like openings: their different symbol must not change
// how light crosses the clear wall tunnel.
const passages = this._openingsR.filter((o) => o.type !== 'window');
@@ -11054,6 +11219,26 @@ class HouseplanCard extends LitElement {
spots.push({ pos, c: glow.c, alpha: colors.glow_light.a * glow.bri, clip, r: R });
}
if (!spots.length) return svg`` as unknown as TemplateResult;
// Per-room Glow overrides are visual clips only. The transport calculation
// above still crosses a disabled room, but no base/pool pixels are painted
// there. For the common all-enabled case this extra clip is omitted, which
// preserves the established doorway-sector geometry byte for byte.
const enabledClip = enabled.length === polys.length ? null : enabled.map(({ r, poly }) => {
const floor = walls.length && r.id
? (innerContourForRoom(
space.rooms, r.id, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
) || poly)
: poly;
const clean = this._cleanFloor(r, floor, space).path;
const holes = islandsOf(
floor,
polys.filter((other) => other.r !== r).map((other) => other.poly),
);
const path = (points: number[][]) =>
'M ' + points.map((point) => point[0] + ' ' + point[1]).join(' L ') + ' Z';
return [clean || path(floor), ...holes.map(path)].join(' ');
});
return svg`<defs>
${spots.map((sp, i) => svg`
<radialGradient id="hp-glow-${i}">
@@ -11062,14 +11247,20 @@ class HouseplanCard extends LitElement {
<stop offset="100%" stop-color="${sp.c}" stop-opacity="0"></stop>
</radialGradient>
${sp.clip ? svg`<clipPath id="hp-glowclip-${i}">${sp.clip.map((d) => svg`<path d="${d}" clip-rule="evenodd" fill-rule="evenodd"></path>`)}</clipPath>` : nothing}`)}
${enabledClip ? svg`<clipPath id="hp-glow-enabled">${enabledClip.map((d) => svg`<path d=${d} clip-rule="evenodd" fill-rule="evenodd"></path>`)}</clipPath>` : nothing}
</defs>
${''/* Glow is presentation only. It is painted above room fills, but must
not become the pointer target: room hover and its tooltip still
belong to the room underneath the light pool. */}
<g class="glowlayer" pointer-events="none" opacity="0.7">
${spots.map((sp, i) => svg`<circle cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
fill="url(#hp-glow-${i})" ${''}
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
<g class="glowlayer glow-pools-frame" pointer-events="none" opacity="0.7">
<g class="glow-pools ${this._glowScreenBlend ? 'blend-screen' : 'blend-normal'}"
data-blend=${this._glowScreenBlend ? 'screen' : 'normal'}
clip-path=${enabledClip ? 'url(#hp-glow-enabled)' : nothing}>
${spots.map((sp, i) => svg`<circle class="glow-pool"
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
fill="url(#hp-glow-${i})"
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
</g>
</g>` as unknown as TemplateResult;
}
@@ -11104,6 +11295,12 @@ class HouseplanCard extends LitElement {
m: String(r.migrated), c: String(r.canonicalized),
w: String(r.wallsMerged), s: String(r.spansMerged),
})}</p>
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`<p class="alignmsg">${this._t('gs.optimize_glow_migration', {
spaces: String(r.glowSpacesMigrated),
rooms: String(r.glowRoomsMigrated),
})}</p>`
: nothing}
<div class="rhint">${this._t('gs.align_warn')}</div>`}
</div>
<div class="row" slot="footer">
@@ -11415,8 +11612,9 @@ class HouseplanCard extends LitElement {
// from the build, so room LQI still counts them (docs/FILTERING.md)
const showGhosts = this._mode === 'devices' && this._showAll;
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const disp = spaceDisplayOf(this._curSpaceCfg);
const disp = this._spaceDisplayForRender();
const roomFills = this._resolvedRoomFills(space, disp);
const glowBase = this._resolvedGlowBase(space, disp);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
@@ -11622,8 +11820,6 @@ class HouseplanCard extends LitElement {
this._roomTemp(r),
);
};
const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(r);
// open boundaries: this room's solid stroke must not run beneath
// the dashed stretches — suppress it and draw a trimmed outline.
// Applies in the Plan editor too (picked rooms keep their full
@@ -11693,13 +11889,14 @@ class HouseplanCard extends LitElement {
d="${trimmed.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`).join(' ')}"
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
: nothing;
return svg`${shape}${outline}${label ? svg`<text class="rlabel"
data-hp="room-label" data-id=${hpId} data-area=${hpArea}
x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
return svg`${shape}${outline}`;
});
})()}
${this._renderOpeningTunnelFills(space, roomFills)}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderGlowBaseRooms(space, glowBase)}
${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')}
${this._renderSvgRoomLabels(space, disp)}
${!this._markup ? this._renderGlowLayer(space, disp) : nothing}
${this._renderSunRays(space)}
${this._editing ? this._renderAlignGuides() : nothing}
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
@@ -12764,7 +12961,9 @@ class HouseplanCard extends LitElement {
this._roomEditId = r.id;
this._nameSel = r.name || '';
this._areaSel = r.area || '';
this._roomFill = (r.settings?.fill_mode as any) || '';
this._roomFill = r.settings?.fill_mode === 'glow'
? ''
: ((r.settings?.fill_mode as any) || '');
this._roomTempSrc = r.settings?.temp_source || '';
this._roomHumSrc = r.settings?.hum_source || '';
this._roomNameScale = clampScale(r.settings?.name_scale);
@@ -12796,8 +12995,23 @@ class HouseplanCard extends LitElement {
}
room.name = this._nameSel.trim() || room.name;
room.area = this._areaSel || null;
const st = this._roomSettingsFromDialog();
if (st) room.settings = st;
// Preserve unknown/future room settings. If this dialog replaces a legacy
// fill_mode:'glow', materialize its effective Glow in the same write so a
// seemingly unrelated room edit cannot switch the light overlay off.
const previous = room.settings || {};
const next: any = { ...previous };
if (this._roomFill) next.fill_mode = this._roomFill;
else delete next.fill_mode;
if (previous.fill_mode === 'glow' && typeof previous.glow !== 'boolean') next.glow = true;
if (this._roomTempSrc) next.temp_source = this._roomTempSrc;
else delete next.temp_source;
if (this._roomHumSrc) next.hum_source = this._roomHumSrc;
else delete next.hum_source;
if (this._roomNameScale !== 1) next.name_scale = this._roomNameScale;
else delete next.name_scale;
if (this._roomLabelScale !== 1) next.label_scale = this._roomLabelScale;
else delete next.label_scale;
if (Object.keys(next).length) room.settings = next;
else delete room.settings;
this._saveConfig();
this._roomDialog = false;
@@ -13989,7 +14203,7 @@ class HouseplanCard extends LitElement {
const controls = (d.controls ?? d.marker?.controls ?? [])
.filter(isControllable).filter((eid) => this._planEntityAvailable(eid));
return html`<hp-dialog .hass=${this.hass} .title=${d.name} .icon=${d.icon} wide
dismiss-on-scrim @hp-close=${() => (this._infoCard = null)}>
dismiss-on-scrim @hp-close=${this._closeInfoCard}>
<div class="body">
${(() => {
// Field feedback: on a wall tablet this card is for CONTROLLING the
@@ -14014,7 +14228,7 @@ class HouseplanCard extends LitElement {
@click=${() => this._cardToggle(eid)}>${val}</button>`
: kind === 'open'
? html`<button class="entbtn"
@click=${() => { this._infoCard = null; this._openMoreInfo(eid); }}>${val}</button>`
@click=${() => { this._closeInfoCard(); this._openMoreInfo(eid); }}>${val}</button>`
: html`<span class="ev">${val}</span>`}
</div>`;
})}
@@ -14052,15 +14266,15 @@ class HouseplanCard extends LitElement {
: nothing}
</div>
<div class="row infofooter" slot="footer">
<button class="btn" @click=${() => { const dd = d; this._infoCard = null; this._openMarkerDialog(dd); }}>
<button class="btn" @click=${() => { const dd = d; this._closeInfoCard(); this._openMarkerDialog(dd); }}>
<ha-icon icon="mdi:pencil"></ha-icon>${this._t('btn.edit')}
</button>
${d.primary
? html`<button class="btn" @click=${() => { const p = d.primary; this._infoCard = null; this._openMoreInfo(p); }}>
? html`<button class="btn" @click=${() => { const p = d.primary; this._closeInfoCard(); this._openMoreInfo(p); }}>
<ha-icon icon="mdi:open-in-new"></ha-icon>${this._t('btn.open_in_ha')}
</button>`
: nothing}
<button class="btn ghost infofooter-close" @click=${() => (this._infoCard = null)}>${this._t('btn.close')}</button>
<button class="btn ghost infofooter-close" @click=${this._closeInfoCard}>${this._t('btn.close')}</button>
</div>
</hp-dialog>`;
}
@@ -14683,6 +14897,12 @@ class HouseplanCard extends LitElement {
: nothing}
</label>`,
)}
<label class="srcrow">
${this._boolInput(d.glowEnabled, (checked) => {
this._spaceDialog = { ...d, glowEnabled: checked };
})}
<span>${this._t('space.glow_enabled')}</span>
</label>
</div>
<div class="row" slot="footer">
${d.mode === 'edit'
+5 -4
View File
@@ -425,8 +425,8 @@
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors, windows and gates, 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",
"fill.glow": "Light sources (dark house, glowing lamps)",
"gs.glow_group": "Light-sources fill",
"space.glow_enabled": "Light-source glow",
"gs.glow_group": "Light-source glow",
"gs.glow_base": "House darkness",
"gs.glow_light": "Default light color / intensity",
"gs.wall_group": "Walls",
@@ -438,7 +438,7 @@
"marker.controls_hint": "With tap action “Toggle”, a click flips every source added here. The marker’s own bound entity is controlled directly and cannot be added; use the switch below to classify it as a light.",
"marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_label": "Glow radius",
"marker.glow_radius_hint": "empty = default from general settings",
"markup.wallthick": "Thickness",
"markup.select": "Select",
@@ -510,7 +510,7 @@
"marker.use_climate_temp": "Use the device's temperature sensor",
"marker.use_climate_temp_tip": "Air conditioners and thermostats know the room temperature (current_temperature). Shows it as a badge next to the icon and counts it in the room average — like a regular thermometer.",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
"marker.is_light_tip": "Creates light-source glow even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
"confirm.unlock": "Unlock “{name}”?",
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}",
"space.pick_saved": "Already uploaded",
@@ -611,6 +611,7 @@
"gs.align_turned": "Openings whose angle is corrected: {n}.",
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {n}.",
"gs.optimize_changes": "Model migrations: {m}; plans canonicalized: {c}; merged real-wall fragments: {w}; virtual fragments: {s}.",
"gs.optimize_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.",
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
"gs.align_run": "Optimize",
"gs.align_done": "Plans optimized: {n} elements moved, {m} records maintained",
+5 -4
View File
@@ -425,8 +425,8 @@
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери, окна и ворота, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
"fill.glow": "Свет по источникам (тёмный дом, пятна света)",
"gs.glow_group": "Заливка «Свет по источникам»",
"space.glow_enabled": "Свечение источников света",
"gs.glow_group": "Свечение источников света",
"gs.glow_base": "Темнота дома",
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
"gs.wall_group": "Стены",
@@ -438,7 +438,7 @@
"marker.controls_hint": "При действии «Переключить» клик разом переключает все добавленные здесь источники. Собственная сущность маркера управляется напрямую и сюда не добавляется; чтобы считать её светом, включите флаг ниже.",
"marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_label": "Радиус свечения",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
"markup.wallthick": "Толщина",
"markup.select": "Выбрать",
@@ -510,7 +510,7 @@
"marker.use_climate_temp": "Использовать датчик температуры устройства",
"marker.use_climate_temp_tip": "Кондиционеры и термостаты знают температуру комнаты (current_temperature). Показывает её плашкой у значка и учитывает в средней температуре комнаты — как обычный термометр.",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
"marker.is_light_tip": "Создаёт свечение источника света даже без light-сущности — для умного выключателя с обычными светильниками. Свечение следует за выключателем (или за привязанными выше лампами).",
"confirm.unlock": "Открыть замок «{name}»?",
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}",
"space.pick_saved": "Уже загруженные",
@@ -611,6 +611,7 @@
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
"gs.optimize_changes": "Миграций модели: {m}; канонизировано планов: {c}; объединено отрезков реальных стен: {w}; виртуальных: {s}.",
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
"gs.align_run": "Оптимизировать",
"gs.align_done": "Планы оптимизированы: сдвинуто элементов — {n}, обслужено записей — {m}",
+25 -11
View File
@@ -819,12 +819,8 @@ export function normalizeDeviceDisplay(value: unknown): DeviceDisplayMode {
? value as DeviceDisplayMode : 'badge';
}
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
// call, it sells the card best. Existing configs keep whatever they chose;
// an absent fill_mode still falls back to 'none' (spaceDisplayOf), so an
// update never repaints somebody's plan.
export const SPACE_FILL_MODES = ['glow', 'none', 'lqi', 'light', 'temp'] as const;
/** Current space-level data fills. Legacy `glow` is read separately as overlay. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
@@ -1242,6 +1238,8 @@ export interface SpaceDisplay {
color: string; // hex like #3ea6ff
opacity: number; // 0..1 — applied to borders, names and fills
fill: RoomFillMode;
/** Independent light-source overlay; legacy fill_mode:'glow' projects here. */
glow: boolean;
tempMin: number; // comfort range lower bound, °C
tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
@@ -1280,12 +1278,15 @@ export const DEFAULT_TEMP_MAX = 25;
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
const s = spaceCfg?.settings || {};
const noPlan = !spaceCfg?.plan_url;
const legacyGlow = s.fill_mode === 'glow';
return {
showBorders: s.show_borders ?? noPlan,
showNames: s.show_names ?? noPlan,
color: safeStoredColor(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', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
// Explicit new data wins. Reading an old config never writes it back.
glow: typeof s.glow_enabled === 'boolean' ? s.glow_enabled : legacyGlow,
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,
@@ -1588,7 +1589,7 @@ export function lightColorOf(state: any): string | null {
return generatedRgbColor(state.attributes?.rgb_color);
}
// ---------------- glow fill (light sources) ----------------
// ---------------- independent light-source Glow overlay ----------------
/** Blackbody color temperature → RGB (Tanner Helland approximation). */
export function kelvinToRgb(kelvin: number): [number, number, number] {
@@ -1895,15 +1896,28 @@ export function contentUrl(url: string | null | undefined): string {
/**
* Effective fill mode of a room: its own override wins, otherwise the space's.
* Four settings tiers (owner's principle, 2026-07-26): global > space > room >
* device; the more specific tier overrides the more general one. A room may
* override even in a glow space ('none' pulls it out of the darkness).
* device; the more specific tier overrides the more general one. The legacy
* room token `glow` means "inherit the data fill and enable Glow"; it is never
* returned as the current data-fill mode.
*/
export function roomFillModeOf(
spaceFill: RoomFillMode,
room: { settings?: { fill_mode?: string | null } | null } | null | undefined,
): RoomFillMode {
const o = room?.settings?.fill_mode;
return o === 'none' || o === 'lqi' || o === 'light' || o === 'temp' ? o : spaceFill;
const inherited = spaceFill === 'glow' ? 'none' : spaceFill;
return o === 'none' || o === 'lqi' || o === 'light' || o === 'temp' ? o : inherited;
}
/** Effective room Glow, independently of its data fill. */
export function roomGlowOf(
spaceGlow: boolean,
room: { settings?: { fill_mode?: string | null; glow?: boolean | null } | null } | null | undefined,
): boolean {
const settings = room?.settings;
if (typeof settings?.glow === 'boolean') return settings.glow;
if (settings?.fill_mode === 'glow') return true;
return spaceGlow;
}
// ---------------- marker files ----------------
+30 -4
View File
@@ -25,7 +25,7 @@ import {
} from './wall-thickness';
/** Bump when a new lossless maintenance pass is added. */
export const PLAN_MODEL_VERSION = 5;
export const PLAN_MODEL_VERSION = 6;
const DEFAULT_CELL_CM = 5;
const CELL_CM_MIN = 0.1;
const CELL_CM_MAX = 1000;
@@ -39,6 +39,10 @@ export interface OptimizeReport extends AlignReport {
modelTo: number;
/** Legacy fields converted or removed. */
migrated: number;
/** Legacy space Glow tokens projected into data fill + overlay. */
glowSpacesMigrated: number;
/** Legacy room Glow tokens projected into inherited fill + overlay. */
glowRoomsMigrated: number;
/** Spaces whose wall/open-span representation was rewritten canonically. */
canonicalized: number;
/** Redundant equal-thickness wall entries removed by canonicalisation. */
@@ -82,8 +86,12 @@ const edgePairs = (rooms: any[]): [number[], number[]][] => {
};
/** Convert read-compatible legacy fields where the conversion is exact. */
const migrateLosslessly = (config: any): number => {
const migrateLosslessly = (config: any): {
total: number; glowSpaces: number; glowRooms: number;
} => {
let n = 0;
let glowSpaces = 0;
let glowRooms = 0;
for (const marker of config.markers || []) {
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
if (Array.isArray(marker.controls)) {
@@ -109,6 +117,21 @@ const migrateLosslessly = (config: any): number => {
}
for (const space of config.spaces || []) {
const spaceSettings = space.settings;
if (spaceSettings?.fill_mode === 'glow') {
if (typeof spaceSettings.glow_enabled !== 'boolean') spaceSettings.glow_enabled = true;
spaceSettings.fill_mode = 'none';
n++;
glowSpaces++;
}
for (const room of space.rooms || []) {
const roomSettings = room.settings;
if (roomSettings?.fill_mode !== 'glow') continue;
if (typeof roomSettings.glow !== 'boolean') roomSettings.glow = true;
delete roomSettings.fill_mode;
n++;
glowRooms++;
}
if (own(space, 'segments')) { delete space.segments; n++; }
if (own(space, 'plan_scale')) {
const k = Number(space.plan_scale);
@@ -215,7 +238,7 @@ const migrateLosslessly = (config: any): number => {
}
}
}
return n;
return { total: n, glowSpaces, glowRooms };
};
/**
@@ -229,7 +252,8 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
const modelFrom = Number.isInteger(Number(config.model_version))
? Number(config.model_version)
: 0;
let migrated = migrateLosslessly(config);
const migration = migrateLosslessly(config);
let migrated = migration.total;
// Materialise legacy open_to before geometry moves. Once explicit spans
// exist, they are the source of truth and open_to becomes a derived index.
@@ -372,6 +396,8 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
modelFrom,
modelTo,
migrated,
glowSpacesMigrated: migration.glowSpaces,
glowRoomsMigrated: migration.glowRooms,
canonicalized,
wallsMerged,
spansMerged,
+10 -2
View File
@@ -17,6 +17,10 @@ export interface OpeningTunnelRenderInput {
openings: readonly RenderOpening[];
geometries: readonly (OpeningTunnelGeometry | null)[];
fillsByRoomId: ReadonlyMap<string, ResolvedRoomFill | null>;
/** Makes gradients unique when data-fill and Glow-base layers coexist. */
idPrefix?: string;
groupClass?: string;
dataLayer?: string;
}
/** Preserve the existing `data-hp`, gradient-id and hard centreline contract. */
@@ -24,6 +28,9 @@ export function renderOpeningTunnelFills({
openings,
geometries,
fillsByRoomId,
idPrefix = 'data',
groupClass = 'opening-tunnels',
dataLayer = 'data',
}: OpeningTunnelRenderInput): TemplateResult {
const parts = openings.map((opening, index) => {
const geometry = geometries[index];
@@ -50,7 +57,8 @@ export function renderOpeningTunnelFills({
const axisOffset = `${(axis * 100).toFixed(6)}%`;
const nf = negFill || { color: '#000000', opacity: 0, mode: 'none' as const };
const pf = posFill || { color: '#000000', opacity: 0, mode: 'none' as const };
const gradientId = `hp-opening-tunnel-${index}`;
const safePrefix = idPrefix.replace(/[^a-zA-Z0-9_-]/g, '-');
const gradientId = `hp-opening-tunnel-${safePrefix}-${index}`;
return svg`<g class="opening-tunnel" data-hp="opening-tunnel" data-id=${opening.id} data-kind=${opening.type}
data-wall-key=${geometry.wallKey} aria-hidden="true" pointer-events="none"
transform=${transform}>
@@ -64,5 +72,5 @@ export function renderOpeningTunnelFills({
<path d=${d} fill=${`url(#${gradientId})`} fill-rule="nonzero"></path>
</g>`;
});
return svg`<g class="opening-tunnels" aria-hidden="true" pointer-events="none">${parts}</g>`;
return svg`<g class=${groupClass} data-layer=${dataLayer} aria-hidden="true" pointer-events="none">${parts}</g>`;
}
+28 -6
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaTemp, resolvedLightSources, resolvedLightState } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, roomGlowOf, stageBgOf, paperRoomShapes } from './logic';
import {
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
@@ -99,6 +99,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const space = models.find((s) => s.id === o.spaceId);
if (!space) return null;
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
const colors = fillColorsOf(o.cfg.settings);
const cfgSize = o.iconSize ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
@@ -183,8 +184,6 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
}
style = parts.join(';');
}
const svgLabel = !space.bg && !disp.showNames;
const c = roomCenter(r);
// docs/STYLING-HOOKS.md §3/§5: the static card carries the same hooks for
// the objects it draws — a card-mod rule written for the plan reads here
// too (in its own block: this is a different card, with its own root).
@@ -195,10 +194,32 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
points="${r.poly.map((p) => p.join(',')).join(' ')}"></polygon>`
: svg`<rect class="${cls}" style="${style}" data-hp="room" data-id=${hpId} data-area=${hpArea}
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"></rect>`;
return svg`${shape}${svgLabel ? svg`<text class="rlabel" data-hp="room-label" data-id=${hpId} data-area=${hpArea}
x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
return shape;
});
// The compact card intentionally has no live radial pools, but it shares the
// exact independent data/base projection with the full plan.
const glowBaseShapes = space.rooms
.filter((room) => roomGlowOf(disp.glow, room))
.map((room) => room.poly
? svg`<polygon class="glow-base" aria-hidden="true" pointer-events="none"
data-room-id=${room.id || nothing}
points="${room.poly.map((point) => point.join(',')).join(' ')}"
fill=${colors.glow_base.c} fill-opacity=${colors.glow_base.a}></polygon>`
: svg`<rect class="glow-base" aria-hidden="true" pointer-events="none"
data-room-id=${room.id || nothing}
x=${room.x} y=${room.y} width=${room.w} height=${room.h}
rx=${Math.min(room.w!, room.h!) * 0.03}
fill=${colors.glow_base.c} fill-opacity=${colors.glow_base.a}></rect>`);
const staticSvgLabels = !space.bg && !disp.showNames
? space.rooms.map((room) => {
const center = roomCenter(room);
return svg`<text class="rlabel" data-hp="room-label"
data-id=${room.id || nothing} data-area=${room.area || nothing}
x=${center[0]} y=${center[1]}>${room.name}</text>`;
})
: [];
const markers = devs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const left = ((p.x - vb[0]) / vb[2]) * 100;
@@ -256,7 +277,6 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const paperShapes = walls.length
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: paperRoomShapes(space.rooms);
const colors = fillColorsOf(o.cfg.settings);
const wallUnion = (walls.length || extras.length) && disp.showBorders
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras)
: null;
@@ -286,6 +306,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
preserveAspectRatio="none" />`
: nothing}
${roomShapes}
<g class="glow-base-layer" aria-hidden="true" pointer-events="none">${glowBaseShapes}</g>
<g class="room-svg-labels" pointer-events="none">${staticSvgLabels}</g>
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
<path class="wallbody-fill" d="${wallUnion.d}"
+18
View File
@@ -496,6 +496,22 @@ export const cardStyles = css`
fill: var(--room-fill, transparent);
fill-opacity: var(--room-fill-op, 0);
}
.glow-base-layer,
.glow-base-tunnels,
.glow-pools-frame,
.glow-pools {
pointer-events: none;
}
/* The isolated group makes source pools blend with one another, never with
the room data fill, Glow base, paper or backdrop. Outer opacity is the
existing 0.7 and is applied once after this flat group is composited. */
.glow-pools-frame,
.glow-pools {
isolation: isolate;
}
.glow-pools.blend-screen .glow-pool {
mix-blend-mode: screen;
}
/* View hover: brighten the current fill; accent wash when unfilled. */
.stage.mode-view .room.styled.filled:hover {
filter: brightness(1.2) saturate(1.08) drop-shadow(0 0 4px var(--hp-accent));
@@ -1150,6 +1166,8 @@ export const cardStyles = css`
.stage.mode-decor .room-outline,
.stage.mode-decor .wallbodies,
.stage.mode-decor .opening-tunnels,
.stage.mode-decor .glow-base-layer,
.stage.mode-decor .glow-pools-frame,
.stage.mode-decor .openwalls {
opacity: 0.35;
}
+4 -1
View File
@@ -8,7 +8,10 @@ export interface RoomCfg {
/** Room-level settings (tier 3 of 4: global > space > ROOM > device). */
settings?: {
/** Fill override; unset = inherit the space fill mode. */
fill_mode?: 'none' | 'lqi' | 'light' | 'temp' | null;
/** `glow` is a permanent legacy read token. */
fill_mode?: 'none' | 'lqi' | 'light' | 'temp' | 'glow' | null;
/** Independent per-room Glow override; null/absent inherits the space. */
glow?: boolean | null;
/** 'device:<id>' or 'entity:<eid>'; unset = average over the room sensors. */
temp_source?: string | null;
hum_source?: string | null;