mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
Release v1.61.0-beta.2 candidate
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user