Files
houseplan-card/src/logic.ts
T
Claude e5ab4659f6 feat: 2.5D stage 6 — public setting, raised tiles, soft sun, theme-free walls (#649)
- settings.volumetric_view (General settings › Display, third switch) replaces
  the alpha entry, the header projection toggle and the phone-menu item; one
  rule for card, sidebar page and kiosk; editors stay Flat; backend accepts
  only a boolean, support package copies it.
- Raised tiles in 2.5D View: no ring, rounded body min(0.275 D, 0.3 h), edge
  0.1 D with the lab filters, ×1.12 size also in layout/collision, lift
  0.075 D, one floor-shadow layer under all markers (theme × floor table),
  frames hugging tile and edge (Alert > Focus > Selected > Hover), forced
  colours without edge and shadow (src/iso-tiles.ts, styles/iso-tiles).
- Soft sun wash instead of projected Flat wedges (src/iso-sun.ts): same gates
  and windows as sun_rays, length from elevation, parallelogram along the sun,
  tone and streaks by floor lightness, sill line.
- Walls take the user's wall colour (top opaque, sides × .77/.68/.60); theme
  rules for walls/openings/labels removed; furniture uses the Flat stroke rule.
- Smokes smoke_iso_tiles/iso_sun/iso_theme_walls/volumetric_setting, 16 new
  mutants, acceptance scenes STAGE6_ACCEPTANCE_SCENARIOS (golden with their
  Linux CI baselines), ACCEPTANCE.md side by side.

Issue: #649
User-Visible: yes
2026-09-25 18:55:14 +03:00

2162 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
*/
import { union } from 'polyclip-ts';
import { generatedRgbColor, safeStoredColor } from './color';
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
export function lqiColor(lqi: number): string {
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
return `hsl(${Math.round(hue)}, 85%, 55%)`;
}
/** Snap a coordinate to the nearest grid node with step pitch.
* A value already ON a node comes back bit-identical: the round-trip through
* a non-dyadic pitch (1000/240) otherwise turns an exact 500 into
* 500.00000000000006, and "is it on the grid?" starts answering no. */
export function snapToGrid(v: number, pitch: number): number {
if (!Number.isFinite(v) || !(pitch > 0)) return v;
const q = Math.round(v / pitch) * pitch;
return Math.abs(q - v) <= pitch * 1e-9 ? v : q;
}
/**
* Nearest grid node on the nearest 45° ray from `anchor` towards `raw`.
*
* Room vertices are always grid-bound. Projecting onto an arbitrary geometric
* ray and snapping x/y independently would break its angle, so the eight valid
* directions use integer grid vectors instead: cardinal (1,0) and diagonal
* (1,1) variants. `limit` caps the number of steps without clipping one axis
* independently, which keeps diagonals at exactly 45° at the canvas edge too.
*/
export function snapSegment45(
anchor: number[], raw: number[], pitch: number, limit = Infinity,
): number[] {
if (!(pitch > 0) || !anchor?.every(Number.isFinite) || !raw?.every(Number.isFinite)) {
return [raw[0], raw[1]];
}
const dx = raw[0] - anchor[0], dy = raw[1] - anchor[1];
if (Math.abs(dx) + Math.abs(dy) <= 1e-12) return [anchor[0], anchor[1]];
const step = Math.PI / 4;
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI * 2;
const octant = Math.floor(angle / step + 0.5) % 8;
const directions = [
[1, 0], [1, 1], [0, 1], [-1, 1],
[-1, 0], [-1, -1], [0, -1], [1, -1],
];
const [vx, vy] = directions[octant];
const denom = vx * vx + vy * vy;
let steps = Math.max(0, Math.round((dx * vx + dy * vy) / (pitch * denom)));
if (Number.isFinite(limit) && limit >= 0) {
const maxSteps = (origin: number, direction: number): number => {
if (!direction) return Infinity;
const room = direction > 0 ? limit - origin : origin + limit;
return Math.max(0, Math.floor((room + pitch * 1e-9) / pitch));
};
steps = Math.min(steps, maxSteps(anchor[0], vx), maxSteps(anchor[1], vy));
}
return [anchor[0] + vx * steps * pitch, anchor[1] + vy * steps * pitch];
}
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
export function segmentCm(a: number[], b: number[], gridPitch: number, cellCm: number): number {
const cells = Math.hypot(b[0] - a[0], b[1] - a[1]) / gridPitch;
return cells * cellCm;
}
/** Format a length (cm) for display: metric metres ("1.25 m") or imperial feet+inches ("4′ 1″"). */
export function formatLength(cm: number, imperial: boolean): string {
if (imperial) {
const totalIn = cm / 2.54;
let ft = Math.floor(totalIn / 12);
let inch = Math.round(totalIn - ft * 12);
if (inch === 12) { ft += 1; inch = 0; }
return `${ft}′ ${inch}″`;
}
return `${(cm / 100).toFixed(2)} m`;
}
/**
* Canonical key of a segment (independent of direction).
* `prec` = decimals used to compare coordinates: the default 1 suits render units,
* normalized (0..1) coordinates need more (see roomEdges).
*/
export function segKey(a: number[], b: number[], prec = 1): string {
// audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
// rounded ones let one wall produce two different keys, so shared walls were
// emitted twice and the dedup invariant quietly broke.
const ax = a[0].toFixed(prec), ay = a[1].toFixed(prec);
const bx = b[0].toFixed(prec), by = b[1].toFixed(prec);
const first = ax < bx || (ax === bx && ay <= by);
const [px, py, qx, qy] = first ? [ax, ay, bx, by] : [bx, by, ax, ay];
return `${px},${py}-${qx},${qy}`;
}
/**
* Wall segments derived from room outlines (normalized coordinates in and out).
*
* A line has no independent existence on the plan: it can only be an edge of a closed
* room. Shared walls are emitted once, which is what makes deleting a room keep the
* borders its neighbours still contribute — the neighbour's polygon still yields them.
*/
export function roomPoly(r: any): number[][] | null {
if (r?.poly?.length >= 3) return r.poly;
if (r && r.x != null && r.y != null && r.w != null && r.h != null)
return [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
return null;
}
/**
* Paper shapes for a hand-drawn plan (owner 2026-08-03): the opaque paper
* follows the ROOM CONTOURS, not their bounding box — an L-shaped house or a
* detached building must not grow a white rectangle around itself. One shape
* per room, in EXACTLY the geometry the room itself renders (same polygon
* points string, same rounded rect), so the paper never peeks past a wall;
* the union of the stack is the paper. Islands simply paint over their
* parent; open (virtual) boundaries do not affect the paper at all.
*/
export function paperRoomShapes(rooms: any[]): Array<
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
for (const r of rooms || []) {
if (r?.poly?.length >= 3) {
out.push({ poly: r.poly.map((p: number[]) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
}
}
return out;
}
export function roomEdges(rooms: any[]): number[][] {
const out: number[][] = [];
const seen = new Set<string>();
for (const r of rooms || []) {
const pts = roomPoly(r);
if (!pts) continue;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
const k = segKey(a, b, 5);
if (seen.has(k)) continue;
seen.add(k);
out.push([a[0], a[1], b[0], b[1]]);
}
}
return out;
}
/**
* Snap a point onto the nearest wall of any room, returning the snapped point and
* the wall's angle (degrees), or null when no wall is within maxDist. Walls are the
* DERIVED room edges (a line has no independent existence on the plan), so an opening
* placed with this stays valid however rooms are later edited — it keeps absolute
* coordinates and is not tied to a room id or edge index.
*/
export interface WallSnapOpts {
/** Quantise the offset along the wall to this step (render or config units). */
step?: number;
/** Length of the thing being placed, so it is kept inside its wall. */
length?: number;
}
export function snapToWall(
p: number[], rooms: any[], maxDist: number, opts: WallSnapOpts = {},
): { x: number; y: number; angle: number } | null {
let best: { x: number; y: number; angle: number } | null = null;
let bestD = maxDist;
for (const e of roomEdges(rooms)) {
const [x1, y1, x2, y2] = e;
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (!len2) continue;
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const q = [x1 + t * dx, y1 + t * dy];
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
if (d < bestD) {
bestD = d;
// normalize to [-90, 90): two rooms sharing a wall yield the same edge in
// OPPOSITE directions — without this, dragging an opening across segment
// boundaries would flip its hinge side back and forth
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (angle >= 90) angle -= 180;
else if (angle < -90) angle += 180;
// docs/CANVAS.md §9: an opening is WALL-bound, so it cannot be rounded to
// a grid node — that would lift it off a diagonal wall. What IS quantised
// is its offset ALONG the wall, in the same step, measured from the
// wall's first corner. On an axis-aligned wall whose corners are on the
// grid (i.e. every wall the editor itself draws) the two agree exactly.
if (opts.step && opts.step > 0) {
const len = Math.sqrt(len2);
const half = Math.min(Math.max(opts.length || 0, 0) / 2, len / 2);
let along = Math.round((t * len) / opts.step) * opts.step;
// the wall's own CENTRE wins inside one step of it: that is the editor's
// magnet (docs/CANVAS.md §9.3), and a batch alignment must not knock a
// deliberately centred window off centre by a couple of centimetres
if (Math.abs(t * len - len / 2) <= opts.step / 2) along = len / 2;
along = Math.max(half, Math.min(len - half, along));
const u = along / len;
best = { x: x1 + u * dx, y: y1 + u * dy, angle };
} else {
best = { x: q[0], y: q[1], angle };
}
}
}
return best;
}
/** Snap the point to the nearest wall EDGE of a single polygon, quantising the
* offset along that edge — the split tool's flavour of the same contract. */
export function snapPointAlongPoly(
p: number[], poly: number[][], step: number,
): number[] | null {
let best: number[] | null = null;
let bestD = Infinity;
for (let i = 0; i < poly.length; i++) {
const [x1, y1] = poly[i];
const [x2, y2] = poly[(i + 1) % poly.length];
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (!len2) continue;
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const d = Math.hypot(p[0] - (x1 + t * dx), p[1] - (y1 + t * dy));
if (d >= bestD) continue;
bestD = d;
const len = Math.sqrt(len2);
const along = step > 0
? Math.max(0, Math.min(len, Math.round((t * len) / step) * step))
: t * len;
const u = along / len;
best = [x1 + u * dx, y1 + u * dy];
}
return best;
}
export interface OpeningShoulders {
/** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
wallA: number[];
wallB: number[];
/** Distance (render units) from each wall end to the NEAREST opening edge, >= 0. */
sideA: number;
sideB: number;
/** Midpoint of each shoulder — where a measure badge sits. */
midA: number[];
midB: number[];
/** Center of the wall edge. */
wallCenter: number[];
/** The opening's center coincides with the wall center (within tol). */
centered: boolean;
}
/**
* Shoulders of an opening being dragged along a wall: distances from the
* opening's edges to the ends of the wall it sits on, plus the "centered on the
* wall" flag (owner 2026-08-03: live ruler badges + a perpendicular dashed tick
* when the opening is exactly in the middle). The wall is ONE room-polygon
* edge — exactly the edge the opening is snapped to (owner 2026-08-03:
* «only the wall of one room», collinear edges of a NEIGHBOURING room are
* never merged in). Selection mirrors snapToWall: among the edges collinear
* with the opening's line, the one nearest to its center wins, first in
* roomEdges order on a tie — so the ruler always measures the same edge the
* drag snapped to. Works for angled walls too: everything is measured along
* the wall's direction. Returns null when the center does not lie on any wall.
*/
export function openingShoulders(
center: number[], angleDeg: number, lengthPx: number, rooms: any[], tol: number, eps = 1.0,
): OpeningShoulders | null {
const rad = (angleDeg * Math.PI) / 180;
const dir = [Math.cos(rad), Math.sin(rad)];
// edges lying on the opening's wall line → pick the ONE nearest to the
// center along that line (t=0 at the center). Same tie-break as snapToWall:
// strictly-nearer wins, so on a tie (shared-boundary fragments, a junction
// vertex) the first edge in roomEdges order is used — the very edge the
// drag snapped to. NO merging of collinear runs across rooms.
let wall: [number, number] | null = null;
let bestD = eps;
for (const e of roomEdges(rooms)) {
const pts = [[e[0], e[1]], [e[2], e[3]]];
const off = (p: number[]) => Math.abs(dir[0] * (p[1] - center[1]) - dir[1] * (p[0] - center[0]));
if (off(pts[0]) > eps || off(pts[1]) > eps) continue; // not collinear with the wall line
const t0 = (pts[0][0] - center[0]) * dir[0] + (pts[0][1] - center[1]) * dir[1];
const t1 = (pts[1][0] - center[0]) * dir[0] + (pts[1][1] - center[1]) * dir[1];
const lo = Math.min(t0, t1), hi = Math.max(t0, t1);
const d = lo > 0 ? lo : hi < 0 ? -hi : 0; // distance from the center to the edge, along the line
if (d < bestD) { bestD = d; wall = [lo, hi]; }
}
if (!wall) return null; // center is not on any edge of this direction
const [tMin, tMax] = wall;
const half = lengthPx / 2;
const sideA = Math.max(0, -half - tMin);
const sideB = Math.max(0, tMax - half);
const at = (t: number) => [center[0] + dir[0] * t, center[1] + dir[1] * t];
const tMid = (tMin + tMax) / 2;
return {
wallA: at(tMin),
wallB: at(tMax),
sideA,
sideB,
midA: at((tMin - half) / 2),
midB: at((half + tMax) / 2),
wallCenter: at(tMid),
centered: Math.abs(tMid) <= tol,
};
}
/**
* How far open an opening is drawn, 0..1, from its contact sensor state.
* No sensor bound → doors and gates default to open (the familiar swing
* symbol), windows to closed (intact glass) — the static-plan convention.
* `unavailable`/`unknown` freeze the default too: an outage must not fake motion.
*/
export function openingAmount(
type: 'door' | 'window' | 'gate' | 'passage', state: string | null | undefined,
invert = false, currentPosition?: unknown,
): number {
if (type === 'passage') return 1;
if (state == null || state === 'unavailable' || state === 'unknown')
return type === 'window' ? 0 : 1;
const rawPosition = typeof currentPosition === 'string'
? (currentPosition.trim() ? Number(currentPosition) : NaN)
: Number(currentPosition);
const base = currentPosition != null && Number.isFinite(rawPosition)
? Math.max(0, Math.min(1, rawPosition / 100))
: (isActiveState(state) ? 1 : 0);
return invert ? 1 - base : base;
}
/** Explicit light allowlist. Unknown future opening types stay fail-dark until
* their physical semantics are deliberately added and tested. */
export function isInteriorLightOpeningType(type: string): type is 'door' | 'gate' | 'passage' {
return type === 'door' || type === 'gate' || type === 'passage';
}
export interface OpeningLightStateEntry {
id: string;
type: string;
contact?: string | null;
amount: number;
}
/**
* #366: one grid for the light pipeline's aperture amount. A moving cover
* publishes current_position by the percent; unquantised it forces a full
* barrier recompute per tick (~100 per gate cycle). Both the signature AND
* the cut geometry consume the same quantised value, so the cache key and
* the drawn aperture agree by construction. 0 and 1 are exact grid nodes —
* binary contact doors are byte-identical to the pre-#366 behaviour.
*/
export const OPENING_LIGHT_AMOUNT_QUANTUM = 0.05;
export function quantizeOpeningLightAmount(amount: number): number {
const safe = Number.isFinite(amount) ? Math.max(0, Math.min(1, amount)) : 0;
return Math.min(1, Math.max(0,
Math.round(safe / OPENING_LIGHT_AMOUNT_QUANTUM) * OPENING_LIGHT_AMOUNT_QUANTUM));
}
/** State-only identity for interior light apertures. Structural geometry is
* fingerprinted separately, so unrelated HA updates cannot evict barriers. */
export function openingLightStateSignature(
openings: readonly OpeningLightStateEntry[],
): string {
return openings
.filter((opening) => !!opening.contact
&& (opening.type === 'door' || opening.type === 'gate'))
.map((opening) => {
const amount = Math.max(0, Math.min(1, Number(opening.amount) || 0));
return `${opening.id}:${amount.toFixed(3)}`;
})
.sort()
.join('|');
}
/** Centre-preserving aperture length used by room-wall and partition cuts. */
export function openingLightApertureLength(length: number, amount: number): number {
const safeLength = Number.isFinite(length) && length > 0 ? length : 0;
const safeAmount = Number.isFinite(amount) ? Math.max(0, Math.min(1, amount)) : 0;
return safeLength * safeAmount;
}
/** Entity bindings that may affect a rendered opening. A passage is purely
* architectural, so stale legacy fields must never create HA subscriptions. */
export function openingEntityReferences(opening: {
type?: string; contact?: string | null; lock?: string | null;
}): string[] {
if (opening.type === 'passage') return [];
return [opening.contact, opening.lock]
.filter((entityId): entityId is string => typeof entityId === 'string' && entityId.length > 0);
}
export interface OpeningEntityCandidate {
value: string;
label: string;
}
/**
* Search opening bindings without changing the candidate resolver's order.
* Contact candidates deliberately put door/window classes first, so this
* helper must only filter and cap the incoming array — never sort it again.
*/
export function filterOpeningEntityCandidates(
candidates: readonly OpeningEntityCandidate[], query: string, limit = 200,
): OpeningEntityCandidate[] {
const normalized = query.trim().toLowerCase();
const filtered = normalized
? candidates.filter((candidate) =>
candidate.label.toLowerCase().includes(normalized)
|| candidate.value.toLowerCase().includes(normalized))
: candidates;
return filtered.slice(0, Math.max(0, limit));
}
/**
* Coarse active/open predicate used by bound architectural openings and
* compatibility helpers. Device activity itself is classified semantically
* in device-visual.ts; it must not use this broad list.
*/
export function isActiveState(state?: string | null): boolean {
return ['on', 'open', 'home', 'detected', 'playing', 'cleaning'].includes(String(state));
}
/** Point equality within a tolerance. */
export function samePoint(a: readonly number[], b: readonly number[], eps = 0.001): boolean {
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
}
/** Point inside a polygon (ray casting). */
export function pointInPolygon(p: number[], poly: number[][]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** Distance from p to segment ab. */
function distToSeg(p: number[], a: number[], b: number[]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/**
* Is p on the outline itself (within eps)? This is the normal case, not an anomaly:
* neighbouring rooms share walls, so their vertices sit on each other's outlines —
* including mid-span, since real walls overlap collinearly rather than match exactly.
*/
/**
* Project a point onto the nearest edge of a polygon and return that point,
* or null when the polygon has no edges. Used to snap a Split click onto the
* actual wall (rooms may not be grid-aligned — imported polygons, older configs),
* so cutting no longer requires hitting a grid node exactly on the outline.
*/
export function closestPointOnBoundary(p: number[], poly: number[][]): number[] | null {
if (!poly || poly.length < 2) return null;
let best: number[] | null = null;
let bestD = Infinity;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const q = [a[0] + t * dx, a[1] + t * dy];
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
if (d < bestD) { bestD = d; best = q; }
}
return best;
}
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
if (!poly || poly.length < 2) return false;
for (let i = 0; i < poly.length; i++)
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return true;
return false;
}
/** Inside the outline AND not on it — a point on a shared wall is not "inside". */
export function pointStrictlyInside(p: number[], poly: number[][], eps = 1e-6): boolean {
if (!poly || poly.length < 3) return false;
if (pointOnBoundary(p, poly, eps)) return false;
return pointInPolygon(p, poly);
}
function cross3(a: number[], b: number[], c: number[]): number {
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}
/**
* Do two segments cross transversally? Touching at an endpoint and collinear overlap
* deliberately do NOT count — that is what sharing a wall looks like.
*/
export function segmentsProperlyCross(
p1: number[], p2: number[], p3: number[], p4: number[], eps = 1e-9,
): boolean {
const d1 = cross3(p3, p4, p1);
const d2 = cross3(p3, p4, p2);
const d3 = cross3(p1, p2, p3);
const d4 = cross3(p1, p2, p4);
return (
((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps))
);
}
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
/**
* A point guaranteed to lie strictly inside the polygon (audit G2).
* The arithmetic mean of the vertices lies OUTSIDE concave shapes (U/L rooms
* are common in hand-drawn plans), which made containment tests misfire.
* Strategy: try the midpoints of the diagonals from each vertex, then a
* triangle centroid of consecutive vertices — the first point that passes
* pointStrictlyInside wins.
*/
/**
* The visual centre of a polygon: the centre of the largest inscribed circle
* (pole of inaccessibility). `interiorPoint` only promises "inside", and for
* an L-shaped room it lands near the seam — the owner's kitchen-living room
* got its settings button visibly off-centre (2026-07-29). Grid search with
* one refinement pass; exact enough for a button, cheap enough to cache.
*/
export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
const xs = poly.map((p) => p[0]);
const ys = poly.map((p) => p[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const span = Math.max(maxX - minX, maxY - minY) || 1;
// Area centroid (shoelace-weighted). Clearance alone has a PLATEAU on any
// elongated room — every point of the long midline fits the same circle —
// and a plain argmax took the first plateau point: left of centre on the
// owner's kitchen, above centre in the sauna. A soft pull toward the
// centroid breaks the tie along the plateau without ever dragging the
// point into a thinner limb (clearance differences dominate the score).
let a2 = 0, cx = 0, cy = 0;
for (let i = 0; i < poly.length; i++) {
const p = poly[i], q = poly[(i + 1) % poly.length];
const cross = p[0] * q[1] - q[0] * p[1];
a2 += cross;
cx += (p[0] + q[0]) * cross;
cy += (p[1] + q[1]) * cross;
}
const centroid = Math.abs(a2) > 1e-9
? [cx / (3 * a2), cy / (3 * a2)]
: [(minX + maxX) / 2, (minY + maxY) / 2];
const clearance = (x: number, y: number): number => {
if (!pointInPolygon([x, y], poly)) return -Infinity;
let d = Infinity;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
d = Math.min(d, distToSegment([x, y], [a[0], a[1], b[0], b[1]]));
}
return d;
};
const score = (x: number, y: number): number => {
const d = clearance(x, y);
if (d === -Infinity) return d;
return d - 0.08 * Math.hypot(x - centroid[0], y - centroid[1]) - 0.0001 * span;
};
let best: number[] | null = null;
let bestS = -Infinity;
for (let i = 1; i < steps; i++) {
for (let j = 1; j < steps; j++) {
const x = minX + ((maxX - minX) * i) / steps;
const y = minY + ((maxY - minY) * j) / steps;
const sc = score(x, y);
if (sc > bestS) { bestS = sc; best = [x, y]; }
}
}
if (best) {
const [bx, by] = best;
const cw = (maxX - minX) / steps, ch = (maxY - minY) / steps;
for (let i = -4; i <= 4; i++) {
for (let j = -4; j <= 4; j++) {
const x = bx + (cw * i) / 4, y = by + (ch * j) / 4;
const sc = score(x, y);
if (sc > bestS) { bestS = sc; best = [x, y]; }
}
}
}
return best || interiorPoint(poly) || poly[0];
}
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
if (!poly || poly.length < 3) return null;
const n = poly.length;
const mean = [
poly.reduce((s, p) => s + p[0], 0) / n,
poly.reduce((s, p) => s + p[1], 0) / n,
];
if (pointStrictlyInside(mean, poly, eps)) return mean;
for (let i = 0; i < n; i++) {
// centroid of the ear at vertex i
const a = poly[(i - 1 + n) % n], b = poly[i], c = poly[(i + 1) % n];
const cand = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3];
if (pointStrictlyInside(cand, poly, eps)) return cand;
}
for (let i = 0; i < n; i++)
for (let j = i + 2; j < n; j++) {
const cand = [(poly[i][0] + poly[j][0]) / 2, (poly[i][1] + poly[j][1]) / 2];
if (pointStrictlyInside(cand, poly, eps)) return cand;
}
return null;
}
function coversArea(a: number[][], b: number[][], eps: number): boolean {
let allOnBoundary = true;
for (const v of a) {
if (pointStrictlyInside(v, b, eps)) return true;
if (!pointOnBoundary(v, b, eps)) allOnBoundary = false;
}
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
if (allOnBoundary) {
const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
return !!c && pointStrictlyInside(c, b, eps);
}
return false;
}
/** Is `inner` fully contained in `outer` (edges may touch, never cross)? */
export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-6): boolean {
if (!outer || !inner || outer.length < 3 || inner.length < 3) return false;
for (let i = 0; i < inner.length; i++)
for (let j = 0; j < outer.length; j++)
if (segmentsProperlyCross(inner[i], inner[(i + 1) % inner.length], outer[j], outer[(j + 1) % outer.length]))
return false;
for (const v of inner)
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
// identical/traced outlines are NOT containment — probe a real interior point
// (audit G2: the vertex mean lies outside concave rooms)
const c = interiorPoint(inner, eps);
return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
}
/**
* Do two room outlines ILLEGALLY share floor area? Sharing a wall (fully or
* partially) and touching at a corner are normal. Since v1.34.0 full nesting is
* legal too (island rooms: a column inside a ring, an inner room) — only edge
* crossings and PARTIAL overlaps are rejected.
*/
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
if (!a || !b || a.length < 3 || b.length < 3) return false;
for (let i = 0; i < a.length; i++)
for (let j = 0; j < b.length; j++)
if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
return coversArea(a, b, eps) || coversArea(b, a, eps);
}
/**
* Direct islands of `poly` among `others`: outlines fully inside it that are not
* themselves inside a bigger island (those are subtracted by their parent).
*/
export function islandsOf(poly: number[][], others: number[][][], eps = 1e-6): number[][][] {
const inside = others.filter((o) => polyContainsPoly(poly, o, eps));
return inside.filter((o) => !inside.some((p) => p !== o && polyContainsPoly(p, o, eps)));
}
/** Shoelace area of an outline (absolute value). */
export function polygonArea(poly: number[][]): number {
if (!poly || poly.length < 3) return 0;
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
function closedRing(poly: number[][]): number[][][] {
return [[...poly.map((p) => [p[0], p[1]]), [poly[0][0], poly[0][1]]]];
}
/**
* Union of two room outlines, or null when they may not be merged.
*
* "Adjacent" is decided by the result rather than by a separate heuristic: only rooms that
* genuinely share a wall (fully or partially — real walls overlap collinearly rather than
* match exactly) collapse into ONE hole-free outline. Rooms that merely touch at a corner,
* that are apart, or whose union would enclose a hole do not, and are refused.
*/
export function mergeRooms(a: number[][], b: number[][]): number[][] | null {
if (!a || !b || a.length < 3 || b.length < 3) return null;
const res = union(closedRing(a) as any, closedRing(b) as any);
if (res.length !== 1) return null; // two pieces → not adjacent
if (res[0].length !== 1) return null; // a ring plus holes → not a simple room
const pts = res[0][0].slice(0, -1).map((p: number[]) => [p[0], p[1]]); // drop the closing point
return pts.length >= 3 ? pts : null;
}
/** Index of the outline edge that p sits on, or -1. */
function edgeIndexOf(poly: number[][], p: number[], eps: number): number {
for (let i = 0; i < poly.length; i++)
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return i;
return -1;
}
function dropRepeats(pts: number[][], eps: number): number[][] {
const out: number[][] = [];
for (const p of pts) if (!out.length || !samePoint(out[out.length - 1], p, eps)) out.push(p);
if (out.length > 1 && samePoint(out[0], out[out.length - 1], eps)) out.pop();
return out;
}
/**
* Cut a room in two with a straight chord between two points on its walls.
* Returns the two parts, or null when the cut is not a clean wall-to-wall chord:
* an end that is not on a wall, a chord that leaves the room (concave outlines) or that
* runs along a wall and would carve off a zero-area sliver.
*/
export function splitRoom(
poly: number[][], a: number[], b: number[], eps = 1e-6,
): [number[][], number[][]] | null {
return splitRoomPath(poly, [a, b], eps);
}
/**
* Split a room along a polyline: first and last points on walls, intermediate
* vertices strictly inside the room. A two-point path is the classic straight
* chord. Returns the two parts, or null when the path is not a clean cut.
*/
export function splitRoomPath(
poly: number[][], pts: number[][], eps = 1e-6,
): [number[][], number[][]] | null {
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
const a = pts[0];
const b = pts[pts.length - 1];
if (samePoint(a, b, eps)) return null;
const ia = edgeIndexOf(poly, a, eps);
const ib = edgeIndexOf(poly, b, eps);
if (ia < 0 || ib < 0) return null; // an end is not on a wall
const mids = pts.slice(1, -1);
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
// no path segment may cross a wall
for (let sI = 0; sI < pts.length - 1; sI++)
for (let i = 0; i < poly.length; i++)
if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
// the path may not properly self-intersect
for (let sI = 0; sI < pts.length - 1; sI++)
for (let t = sI + 2; t < pts.length - 1; t++)
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
// a straight chord lying along a wall has its midpoint ON the outline, not inside
if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
return null;
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
const acc: number[][] = [from];
let i = (fromIdx + 1) % poly.length;
for (let guard = 0; guard <= poly.length; guard++) {
acc.push(poly[i]);
if (i === toIdx) break;
i = (i + 1) % poly.length;
}
acc.push(to);
return dropRepeats(acc, eps);
};
let p1: number[][];
let p2: number[][];
if (ia === ib) {
// BOTH ends on the SAME edge — carving an alcove out of one wall. The walk
// above would traverse the whole outline twice and return two overlapping,
// self-intersecting rooms whose areas sum to 2x the original (audit G1,
// 2026-07-27). The niche is simply the path closed along that edge; the
// remainder is the outline with that stretch replaced by the path.
const niche = dropRepeats([...pts], eps);
if (niche.length < 3 || polygonArea(niche) <= eps) return null;
// the niche must not swallow other geometry: it stays inside the room
const rest: number[][] = [];
for (let i = 0; i < poly.length; i++) {
rest.push(poly[i]);
if (i === ia) {
// walk the cut from a to b along the edge direction
const dir = (poly[(ia + 1) % poly.length][0] - poly[ia][0]) * (b[0] - a[0])
+ (poly[(ia + 1) % poly.length][1] - poly[ia][1]) * (b[1] - a[1]);
const path = dir >= 0 ? pts : [...pts].reverse();
for (const p of path) rest.push(p);
}
}
p1 = dropRepeats(rest, eps);
p2 = niche;
} else {
p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
}
if (p1.length < 3 || p2.length < 3) return null;
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
// INVARIANT (audit G1): a split partitions the room — the parts must sum to
// the original. Anything else means the walk produced overlapping garbage.
if (Math.abs(polygonArea(p1) + polygonArea(p2) - polygonArea(poly)) > Math.max(eps, polygonArea(poly) * 1e-6))
return null;
return [p1, p2];
}
/**
* Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
* virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
*/
export function markerIdForBinding(
binding: string,
existingId: string | undefined,
newId: () => string,
): string {
const [kind, ref] = binding.split(':');
if (kind === 'device') return ref;
if (kind === 'entity') return 'lg_' + ref;
return existingId && existingId.startsWith('v_') ? existingId : newId();
}
/** Average LQI over a set of values (or null). */
export function averageLqi(values: number[]): number | null {
if (!values.length) return null;
return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
}
/** “Contain” rectangle with the given aspect (w/h) that fits the whole vb [x,y,w,h]. */
export function fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } {
const planA = vb[2] / vb[3];
if (aspect > planA) {
const h = vb[3], w = vb[3] * aspect;
return { x: vb[0] - (w - vb[2]) / 2, y: vb[1], w, h };
}
const w = vb[2], h = vb[2] / aspect;
return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h };
}
/** Push points apart: no closer than minDist to each other, within rectangle b with padding pad. Mutates pts. */
export function declump(
pts: { x: number; y: number }[],
b: { x: number; y: number; w: number; h: number },
minDist: number,
pad: number,
): void {
if (pts.length < 2) return;
const minX = b.x + pad, maxX = b.x + b.w - pad, minY = b.y + pad, maxY = b.y + b.h - pad;
for (let it = 0; it < 60; it++) {
let moved = false;
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const dx = pts[j].x - pts[i].x, dy = pts[j].y - pts[i].y;
const dist = Math.hypot(dx, dy) || 0.001;
if (dist < minDist) {
const push = (minDist - dist) / 2;
const ux = dx / dist, uy = dy / dist;
pts[i].x -= ux * push; pts[i].y -= uy * push;
pts[j].x += ux * push; pts[j].y += uy * push;
moved = true;
}
}
}
for (const q of pts) {
q.x = Math.max(minX, Math.min(maxX, q.x));
q.y = Math.max(minY, Math.min(maxY, q.y));
}
if (!moved) break;
}
}
/**
* Safe URL for <a href>: only http(s) and relative paths are allowed.
* Rejects javascript:, data: and other dangerous schemes (XSS via config).
*/
export function safeUrl(url: string | null | undefined): string | null {
if (!url) return null;
const u = url.trim();
if (/^(https?:)?\/\//i.test(u) || u.startsWith('/') || /^[\w./#?=&%~-]+$/i.test(u)) {
if (/^[a-z][\w+.-]*:/i.test(u) && !/^https?:/i.test(u)) return null;
return u;
}
return null;
}
// ---------------- tap actions ----------------
/**
* The option lists the editors offer, in one place — and the reason they are
* here rather than inline in the templates.
*
* `display` gained 'value' in v1.26.0 ("show the measurement instead of the
* icon") but the backend schema still only accepted badge/ripple/icon_ripple,
* so saving any marker configured that way was rejected outright — and since
* one bad marker fails the whole config write, the plan could not be saved at
* all. Shipped 2026-07-21, found by a user on 2026-07-27: six days, and only
* because they pasted the error text. Nothing in the suite could have caught
* it, because the option list and the schema that stores it were written in
* two languages and never compared. They are exported here so a backend test
* can read them and assert the schema accepts every value a user can pick.
* Adding an option here and forgetting the schema now fails the test suite.
*/
/** UI presentations. `ripple` remains a backend/read compatibility value only
* and is normalised to `icon_ripple` by the shared normalizer. */
export const DISPLAY_MODES = ['badge', 'icon_ripple', 'value', 'static_icon', 'value_static_icon'] as const;
export type DeviceDisplayMode = typeof DISPLAY_MODES[number];
/* The four original modes tied two independent things together: what is drawn
* inside the marker (icon or value) and whether the marker takes colour from
* state. `value_static_icon` (#588) is the missing fourth combination, so the
* two questions are asked separately from here on. Every renderer, the pulse
* layer, the badge layer and the live vacuum branches ask these two predicates
* instead of comparing the token, which is what kept the modes consistent. */
/** The marker draws the resolved value instead of its icon when one exists. */
export const displayWantsValue = (display: DeviceDisplayMode): boolean =>
display === 'value' || display === 'value_static_icon';
/** State, alarm, availability, live colour and activity never touch the face. */
export const displayIsNeutral = (display: DeviceDisplayMode): boolean =>
display === 'static_icon' || display === 'value_static_icon';
/** One read boundary for persisted/legacy display values. Unknown manual data
* safely falls back to the default dynamic badge; the backend still rejects it
* on write. Keep every renderer and editor on this normalizer. */
export function normalizeDeviceDisplay(value: unknown): DeviceDisplayMode {
if (value === 'ripple') return 'icon_ripple';
return (DISPLAY_MODES as readonly unknown[]).includes(value)
? value as DeviceDisplayMode : 'badge';
}
/** Current editor choices. `cover` remains a read/backend compatibility token. */
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'none'] as const;
export type DeviceTapAction = typeof TAP_ACTIONS[number];
/** Persisted space-level data fills. `none` remains a read-compatibility token;
* the space editor projects it to `custom` because every room has a visible
* base colour and the user can now choose that colour directly. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'custom'] as const;
/** Choices written by the current space editor. Keep `none` available only to
* old configs and to the room-level override that disables an inherited fill. */
export const SPACE_FILL_UI_MODES = ['custom', 'lqi', 'light', 'temp'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'custom'] as const;
/** Cover classes that stay OUT of the deprecated card-wide toggle. The shared
* runtime resolver also blocks them for an explicit action, but does so after
* resolving the exact target so the UI can explain why the command is a no-op. */
export const COVER_GUARDED_CLASSES = new Set(['garage', 'door', 'gate']);
/** Is a cover travelling right now? Drives the breathing ring on the icon. */
export function coverMoving(state: string | null | undefined): boolean {
return state === 'opening' || state === 'closing';
}
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
* HA. An automation is triggered, a script and a scene are turned on. */
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
/** Service to start a runnable target, or null when the id is not runnable. */
export function runServiceFor(target: string | null | undefined): { domain: string; service: string } | null {
const dom = String(target || '').split('.')[0];
if (dom === 'automation') return { domain: 'automation', service: 'trigger' };
if (dom === 'script') return { domain: 'script', service: 'turn_on' };
if (dom === 'scene') return { domain: 'scene', service: 'turn_on' };
return null;
}
// ---------------- floors import ----------------
export interface FloorInfo {
id: string;
name: string;
level: number | null;
}
/** HA floor registry → a list ordered by level (unknown levels last), then name. */
export function floorsOf(hass: any): FloorInfo[] {
const reg = hass?.floors;
if (!reg || typeof reg !== 'object') return [];
const list: FloorInfo[] = [];
for (const f of Object.values<any>(reg)) {
if (!f || !f.floor_id) continue;
list.push({ id: f.floor_id, name: f.name || f.floor_id, level: f.level ?? null });
}
list.sort((a, b) => {
const la = a.level ?? 1e9;
const lb = b.level ?? 1e9;
return la !== lb ? la - lb : a.name.localeCompare(b.name);
});
return list;
}
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) -------------
/** What a dead sensor says. A label that vanishes with its entity is worse
* than one that admits it has no data. */
export const LIVE_TEXT_DASH = '—';
/** A caption is a caption: an attribute that turns out to be a 4 KB string
* must not become the plan's wallpaper. */
export const LIVE_TEXT_VALUE_MAX = 60;
/** Legacy one-value placeholder. New labels store complete `{entity[:attr]}`
* references in `text`; this stays readable for existing configurations. */
export const LIVE_TEXT_SLOT = '{}';
export interface LiveTextLink {
/** entity id whose value lands in the label; absent = a plain static label */
entity?: string | null;
/** attribute to read instead of the state */
attr?: string | null;
/** suffix; absent = the entity's own unit_of_measurement (state only) */
unit?: string | null;
}
/**
* A reference written directly in decor text.
*
* Canonical form is `{domain.object_id}` for state and
* `{domain.object_id:attribute}` for an attribute. For hand-written labels we
* also accept `{domain.object_id.attribute}`: the first two dot-separated
* parts are the entity id and the rest is the flat attribute name.
*/
export function liveTextReference(raw: string | null | undefined): LiveTextLink | null {
const ref = String(raw ?? '').trim();
if (!ref) return null;
let entity = ref;
let attr = '';
const colon = ref.indexOf(':');
if (colon >= 0) {
entity = ref.slice(0, colon).trim();
attr = ref.slice(colon + 1).trim();
} else {
const parts = ref.split('.');
if (parts.length > 2) {
entity = parts.slice(0, 2).join('.');
attr = parts.slice(2).join('.');
}
}
if (!/^[a-z0-9_]+\.[a-z0-9_]+$/.test(entity)) return null;
if (colon >= 0 && !attr) return null;
if (attr && !/^[a-zA-Z0-9_.-]+$/.test(attr)) return null;
return attr ? { entity, attr } : { entity };
}
/** Canonical token inserted by the text editor. */
export function liveTextToken(entity: string | null | undefined, attr?: string | null): string {
const id = String(entity ?? '').trim();
const name = String(attr ?? '').trim();
const ref = liveTextReference(name ? `${id}:${name}` : id);
if (!ref) return '';
return `{${ref.entity}${ref.attr ? `:${ref.attr}` : ''}}`;
}
/** One attribute/state value as text, or null when there is nothing to show. */
function liveRaw(raw: unknown): string | null {
if (raw === undefined || raw === null) return null;
if (Array.isArray(raw)) {
const s = raw.map((v) => (v === null || v === undefined ? '' : String(v))).join(', ');
return s ? s.slice(0, LIVE_TEXT_VALUE_MAX) : null;
}
if (typeof raw === 'object') return null; // a dict is not a caption
const s = String(raw);
return s === '' ? null : s.slice(0, LIVE_TEXT_VALUE_MAX);
}
/**
* What one printing site got out of `hassValue`.
* `formatted` answers the only question a caller has afterwards: did HOME
* ASSISTANT build this string (unit already inside — never append one), or
* did we fall back to the raw state (the unit, if any, is still ours to add)?
*/
export interface HassValue {
text: string;
formatted: boolean;
}
/**
* One entity value, printed the way HOME ASSISTANT would print it
* (docs/STYLING-HOOKS.md §6).
*
* This is the single wrapper every value on the plan goes through. It does
* NOT round, localise or translate anything of its own — it hands the state
* object to `hass.formatEntityState` (or `hass.formatEntityAttributeValue`
* for an attribute), which is the very call HA's own more-info makes, so the
* number obeys the entity's `display_precision`, the user's decimal separator
* and the state translations. Our old rule ("we do not reformat") is not
* revoked, it is delegated: the formatting belongs to the side that knows the
* user's settings.
*
* An older Home Assistant has no such method — then `formatted` is false and
* `text` is exactly what we printed before, so nothing breaks and no version
* check is needed anywhere else.
*
* Returns null when there is nothing to print: no entity id, no such entity,
* an empty state, a missing attribute, or an attribute that is a dict.
*/
export function hassValue(
hass: any, entityId: string | null | undefined, attr?: string | null,
): HassValue | null {
const id = String(entityId ?? '').trim();
if (!id) return null;
const st = hass?.states?.[id];
if (!st) return null;
const name = String(attr ?? '').trim();
const clip = (v: string) => v.slice(0, LIVE_TEXT_VALUE_MAX);
if (name) {
const bare = liveRaw(st.attributes?.[name]);
if (bare === null) return null; // not on this entity, or a dict
const f = hass?.formatEntityAttributeValue;
if (typeof f === 'function') {
// a formatter that throws is a formatter we do not have (an older HA
// may know the name but not this attribute's shape)
try {
const out = f.call(hass, st, name);
if (typeof out === 'string' && out !== '') return { text: clip(out), formatted: true };
} catch { /* fall through to the raw value */ }
}
return { text: bare, formatted: false };
}
const raw = st.state;
if (raw === undefined || raw === null || raw === '') return null;
const f = hass?.formatEntityState;
if (typeof f === 'function') {
try {
const out = f.call(hass, st);
if (typeof out === 'string' && out !== '') return { text: clip(out), formatted: true };
} catch { /* fall through to the raw state */ }
}
return { text: clip(String(raw)), formatted: false };
}
/**
* Exact trailing match on `unit` only — nothing else in the string is touched,
* and a text that does not end with it comes back untouched.
*/
function withoutUnit(text: string, unit: string): string {
if (!unit) return text;
const t = text.replace(/\s+$/, '');
return t.endsWith(unit) ? t.slice(0, t.length - unit.length).replace(/\s+$/, '') : text;
}
/**
* A formatted value with the unit it should end in — and with it there exactly
* ONCE (docs/STYLING-HOOKS.md §6).
*
* HA's `formatEntityState` normally appends the entity's own unit itself, so
* blindly adding `own` would double it and blindly trusting it would drop the
* unit on the versions/entities where it does not. The rule that survives both:
* strip the entity's own unit if it is already the tail, then append the unit
* the caller actually wants — the user's explicit one when there is one, the
* entity's own otherwise. With no unit wanted at all the text is returned
* untouched, so a translated state («Включено») never grows a suffix.
*/
export function valueWithUnit(v: HassValue, own: string, explicit?: string | null): string {
const ownU = String(own ?? '').trim();
const wanted = String(explicit ?? '').trim() || ownU;
if (!wanted) return v.text;
const bare = v.formatted && ownU ? withoutUnit(v.text, ownU) : v.text;
return `${bare} ${wanted}`;
}
/**
* The live value of a linked label, unit included — formatted the way HOME
* ASSISTANT formats it (docs/LIVE-TEXT.md §2.1, docs/STYLING-HOOKS.md §6).
*
* We still write no rounding logic of our own: the value goes through
* `hassValue`, which hands it to HA's formatter, so `display_precision`, the
* decimal separator and the state translations are the user's HA settings and
* not two sources of truth. Without a formatter (an older HA) this is
* byte-for-byte the previous behaviour.
*
* Unit resolution: an explicit `unit` always wins. An empty one inherits the
* entity's `unit_of_measurement` ONLY when the STATE is being read — a
* `battery_level` attribute on a °C sensor must not come out as «73 °C» —
* and only when the formatter has not already put it there.
*/
export function liveTextValue(hass: any, link: LiveTextLink | null | undefined): string {
const id = (link?.entity || '').trim();
if (!id) return '';
const st = hass?.states?.[id];
const state = st?.state;
if (!st || state === undefined || state === null || state === ''
|| state === 'unavailable' || state === 'unknown') return LIVE_TEXT_DASH;
const attr = (link?.attr || '').trim();
const v = hassValue(hass, id, attr || null);
if (v === null) return LIVE_TEXT_DASH; // the attribute is not on this entity
// the unit the entity carries — inherited by the STATE only
const own = attr ? '' : String(st.attributes?.unit_of_measurement ?? '').trim();
return valueWithUnit(v, own, link?.unit);
}
/**
* The label as it must be painted. Every valid `{entity}` / `{entity:attr}`
* reference is resolved independently, so ordinary text and any number of HA
* values may be mixed in one caption. Invalid brace contents stay literal.
*
* `link` is the pre-template storage format. It is intentionally evaluated
* only when the text has no new references, keeping old saved labels working
* until the user edits and saves them in the new UI.
*/
export function liveText(
text: string | null | undefined,
link: LiveTextLink | null | undefined,
hass: any,
entityAvailable: (entityId: string) => boolean = () => true,
): string {
const tpl = text ?? '';
let referenced = false;
const rendered = tpl.replace(/\{([^{}\r\n]+)\}/g, (whole, raw: string) => {
const ref = liveTextReference(raw);
if (!ref) return whole;
referenced = true;
if (!entityAvailable(ref.entity || '')) return LIVE_TEXT_DASH;
return liveTextValue(hass, ref);
});
if (referenced) return rendered;
// Backward compatibility for shapes saved by beta.9 and earlier.
const linkedEntity = (link?.entity || '').trim();
if (!linkedEntity) return tpl;
if (!entityAvailable(linkedEntity)) {
const i = tpl.indexOf(LIVE_TEXT_SLOT);
if (i >= 0) return tpl.slice(0, i) + LIVE_TEXT_DASH + tpl.slice(i + LIVE_TEXT_SLOT.length);
return tpl ? `${tpl} ${LIVE_TEXT_DASH}` : LIVE_TEXT_DASH;
}
const v = liveTextValue(hass, link);
const i = tpl.indexOf(LIVE_TEXT_SLOT);
if (i >= 0) return tpl.slice(0, i) + v + tpl.slice(i + LIVE_TEXT_SLOT.length);
return tpl ? `${tpl} ${v}` : v;
}
export const DECOR_TEXT_BASE = 20; // px at scale 1 — what 'm' has always been
export const DECOR_TEXT_SCALE_MIN = 0.15;
export const DECOR_TEXT_SCALE_MAX = 20;
/**
* Read the legacy font multiplier of a decor text shape. Canonical new writes
* use physical `size_cm`; this helper keeps old `scale` and `size`
* ('s'|'m'|'l') visually stable. `size` is read as the multiplier it
* used to render at (14/20/30 px against the base 20), so a label drawn
* before the handles existed comes back at exactly its old size without any
* migration. An explicit `scale` wins among the legacy representations.
*/
export function decorTextScale(shape: { scale?: unknown; size?: unknown } | null | undefined): number {
const s = Number(shape?.scale);
if (Number.isFinite(s) && s > 0) return Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, s));
const legacy: Record<string, number> = { s: 0.7, m: 1, l: 1.5 };
return legacy[String(shape?.size ?? '')] ?? 1;
}
/** The lines of a decor label. Explicit newlines only: the label never wraps
* by itself, so a caption cannot reflow (and jump) on a state change. */
export function decorTextLines(s: string | null | undefined): string[] {
return String(s ?? '').replace(/\r\n?/g, '\n').split('\n');
}
/** Substitute every occurrence of {name} placeholders in a template string. */
export function subst(s: string, vars?: Record<string, string | number>): string {
if (!vars) return s;
let out = s;
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
return out;
}
// ---------------- room fills & colors ----------------
/** Effective data/static fill. Legacy persisted `glow` is projected separately. */
export type RoomFillMode = typeof SPACE_FILL_MODES[number];
/** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay {
showBorders: boolean;
showNames: boolean;
color: string; // hex like #3ea6ff
opacity: number; // 0..1 — applied to borders, names and fills
fill: RoomFillMode;
/** Resolved space-level custom fill; rooms may override it. */
customFill: FillColorEntry;
/** 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. */
showLqi: boolean | null;
/** Base font multiplier for room cards (tier 2; rooms multiply on top). */
cardFontScale: number;
/** Room-card metrics under the room name (all default off). */
labelTemp: boolean;
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
/** Background around the plan; null = inherit the global setting / theme. */
bgColor: string | null;
/**
* Two "draw less" switches (owner 2026-08-05). Both are OPT-IN and both are
* lifted inside the editor that owns the layer — a hidden thing you cannot
* see to edit is a trap, so the decor editor always shows decor and the plan
* editor always shows openings, whatever these say.
*/
hideDecor: boolean;
hideOpenings: boolean;
}
// Default for borders + room names when room_color is absent. Dark slate
// grey since 2026-08-03 (owner call, was the accent '#3ea6ff'): reads on
// the white paper of drawn plans and on the glow-dark theme alike.
// CHANGELOG draft: "Default room border/name colour is now dark grey
// (#55606c). Spaces with an explicitly chosen room_color keep it; only
// spaces that never touched the colour pick up the new default."
export const DEFAULT_ROOM_COLOR = '#55606c';
export const DEFAULT_ROOM_OPACITY = 0.55;
export const DEFAULT_TEMP_MIN = 20;
export const DEFAULT_TEMP_MAX = 25;
export const DEFAULT_CUSTOM_FILL: FillColorEntry = { c: '#607d8b', a: 0.18 };
/** Safe read boundary for a persisted custom fill. Invalid legacy/future data
* is projected to a known color without silently rewriting the config. */
export function customFillOf(value: unknown, fallback: FillColorEntry = DEFAULT_CUSTOM_FILL): FillColorEntry {
const raw = value && typeof value === 'object' ? value as any : null;
const alpha = raw?.a;
return {
c: safeStoredColor(raw?.c, fallback.c),
a: typeof alpha === 'number' && Number.isFinite(alpha)
? Math.min(1, Math.max(0, alpha)) : fallback.a,
};
}
/**
* Effective custom fill: the room's own colour -> space colour -> product default.
*
* The room colour counts only together with the room's OWN `fill_mode: 'custom'`
* (#581). A colour stored without that mode — a room switched back to "as the
* space" by an older editor, or a legacy `glow` token — is an orphan: the
* dialog never named that state, so the plan must not paint it. Reading the
* orphan does not rewrite the config; the next save of that room drops it.
*/
export function roomCustomFillOf(
spaceCustom: unknown,
room: { settings?: { fill_mode?: string | null; custom_fill?: unknown } | null } | null | undefined,
): FillColorEntry {
const spaceFill = customFillOf(spaceCustom);
const settings = room?.settings;
if (settings?.fill_mode !== 'custom') return spaceFill;
const own = settings?.custom_fill;
return own && typeof own === 'object' ? customFillOf(own, spaceFill) : spaceFill;
}
export interface RoomTempRange {
min: number;
max: number;
}
/**
* Effective comfort range for one room. Each side inherits independently,
* then the complete pair is normalised. Invalid legacy/future values behave
* like an absent override and are never written back by this read boundary.
*/
export function roomTempRangeOf(
spaceMin: number,
spaceMax: number,
room: {
settings?: { temp_min?: unknown; temp_max?: unknown } | null;
} | null | undefined,
): RoomTempRange {
const finiteOr = (value: unknown, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
const inheritedMin = finiteOr(spaceMin, DEFAULT_TEMP_MIN);
const inheritedMax = finiteOr(spaceMax, DEFAULT_TEMP_MAX);
const rawMin = finiteOr(room?.settings?.temp_min, inheritedMin);
const rawMax = finiteOr(room?.settings?.temp_max, inheritedMax);
return { min: Math.min(rawMin, rawMax), max: Math.max(rawMin, rawMax) };
}
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
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', 'custom'].includes(s.fill_mode) ? s.fill_mode : 'none',
customFill: customFillOf(s.custom_fill),
// 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,
cardFontScale: typeof s.card_font_scale === 'number' && s.card_font_scale > 0
? Math.min(3, Math.max(0.5, s.card_font_scale))
: 1,
labelTemp: s.label_temp === true,
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
bgColor: safeStoredColor(s.bg_color, null),
// absent = false = today's rendering, so no plan changes by being read
hideDecor: s.hide_decor === true,
hideOpenings: s.hide_openings === true,
};
}
/**
* Effective background color around the plan: the per-space override wins,
* then the global config.settings.bg_color, then '' — meaning "keep the
* theme default" (the stage's stylesheet background stays untouched).
*/
export function stageBgOf(settings: any, disp: { bgColor: string | null }): string {
if (disp.bgColor) return disp.bgColor;
const g = settings?.bg_color;
return safeStoredColor(g, '');
}
/** Global room-hover information preference. Legacy and malformed values keep
* the historical default; only an explicit boolean false disables the window. */
export function showRoomTooltipOf(settings: unknown): boolean {
return (settings as { show_room_tooltip?: unknown } | null | undefined)
?.show_room_tooltip !== false;
}
/** #649: 2.5D is one installation-wide View choice; only an explicit true enables it. */
export function volumetricViewOf(settings: unknown): boolean {
return (settings as { volumetric_view?: unknown } | null | undefined)?.volumetric_view === true;
}
// ---------------- global fill colors ----------------
export interface FillColorEntry {
c: string; // #rrggbb
a: number; // 0..1 fill opacity
}
/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
export interface FillColors {
light_on: FillColorEntry;
light_off: FillColorEntry;
/** Rooms with no light sources at all; alpha 0 (default) = no fill, as before. */
light_none: FillColorEntry;
temp_cold: FillColorEntry;
temp_ok: FillColorEntry;
temp_hot: FillColorEntry;
lqi_low: FillColorEntry;
lqi_high: FillColorEntry;
/** Glow darkness for rooms whose effective data/static fill is `none`. */
glow_base: FillColorEntry;
glow_light: FillColorEntry;
/** Thick-wall body fill (docs/WALL-THICKNESS.md); default opaque white. */
wall_fill: FillColorEntry;
}
export const DEFAULT_FILL_COLORS: FillColors = {
light_on: { c: '#ffd45c', a: 0.18 },
light_off: { c: '#9aa0a6', a: 0.14 },
light_none: { c: '#6b7480', a: 0 },
temp_cold: { c: '#4fc3f7', a: 0.18 },
temp_ok: { c: '#66d17a', a: 0.18 },
temp_hot: { c: '#ffd45c', a: 0.18 },
lqi_low: { c: '#f25a4a', a: 0.18 },
lqi_high: { c: '#4bd28f', a: 0.18 },
glow_base: { c: '#0d1b2a', a: 0.5 },
glow_light: { c: '#ffd9a0', a: 0.85 },
wall_fill: { c: '#ffffff', a: 1 },
};
/** Merge stored overrides over the defaults, dropping malformed entries. */
export function fillColorsOf(settings: any): FillColors {
const out: any = {};
const src = settings?.fill_colors || {};
for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
const d = DEFAULT_FILL_COLORS[k];
const v = src[k];
out[k] = {
c: safeStoredColor(v?.c, d.c),
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
};
}
return out as FillColors;
}
/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
export function lerpColor(a: string, b: string, t: number): string {
const tt = Math.min(1, Math.max(0, t));
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
}
/**
* Room fill (color + opacity) for the selected mode, or null for "no fill",
* using the global palette. The LQI gradient interpolates lqi_low → lqi_high
* over the 40..180 LQI window (same thresholds as the badge color).
*/
export function roomFillStyle(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp: number | null | undefined,
tempMin: number,
tempMax: number,
colors: FillColors,
customFill: FillColorEntry = DEFAULT_CUSTOM_FILL,
): FillColorEntry | null {
if (mode === 'custom') return customFillOf(customFill);
if (mode === 'lqi') {
if (lqi == null) return null;
const t = (lqi - 40) / 140;
return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
}
if (mode === 'light') {
if (lights === 'none') {
// configurable "no light sources" color; alpha 0 keeps the historical
// no-fill behavior (and the unfilled hover), so nothing changes until
// the user assigns an opacity
return colors.light_none.a > 0 ? colors.light_none : null;
}
return lights === 'on' ? colors.light_on : colors.light_off;
}
if (mode === 'temp') {
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return colors.temp_cold;
if (temp > hi) return colors.temp_hot;
return colors.temp_ok;
}
return null;
}
/**
* Renderer-ready room fill shared by the room shape and every piece of floor
* that visually extends it (currently thick-wall opening tunnels).
*
* Keep live-data collection outside this pure helper. Callers resolve the
* room's LQI/light/temperature once per frame, then both render paths consume
* this exact color/opacity pair. This preserves the historical room palette
* while preventing a second, subtly different fill implementation.
*/
export interface ResolvedRoomFill {
color: string;
opacity: number;
mode: RoomFillMode | 'glow';
}
export function resolveEffectiveRoomFill(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp: number | null | undefined,
tempMin: number,
tempMax: number,
colors: FillColors,
customFill: FillColorEntry = DEFAULT_CUSTOM_FILL,
): ResolvedRoomFill | null {
const entry = roomFillStyle(mode, lqi, lights, temp, tempMin, tempMax, colors, customFill);
return entry ? { color: entry.c, opacity: entry.a, mode } : null;
}
/**
* Room fill color for the selected mode, or null for "no fill".
* - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
* - light: warm yellow when any light in the room is on, grey when all known
* lights are off; rooms without lights get no fill (a bathroom without smart
* bulbs should not look permanently "off").
*/
export function roomFillColor(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp?: number | null,
tempMin: number = DEFAULT_TEMP_MIN,
tempMax: number = DEFAULT_TEMP_MAX,
): string | null {
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
if (mode === 'light') {
if (lights === 'none') return null;
return lights === 'on' ? '#ffd45c' : '#9aa0a6';
}
if (mode === 'temp') {
// blue below the comfort range, green inside, yellow above; no reading → no fill.
// Bounds are swapped automatically if entered in the wrong order.
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return '#4fc3f7';
if (temp > hi) return '#ffd45c';
return '#66d17a';
}
return null;
}
// ---------------- state-reflecting icons ----------------
/**
* cover device_class -> [closed icon, open icon] (owner's spec 2026-08-03).
* Same idea as core HA's cover icons; kept here so the plan's morphing lives
* in one table with the door/window/lock pairs below.
*/
const COVER_ICONS: Record<string, [string, string]> = {
blind: ['mdi:blinds', 'mdi:blinds-open'],
shade: ['mdi:blinds', 'mdi:blinds-open'],
shutter: ['mdi:window-shutter', 'mdi:window-shutter-open'],
curtain: ['mdi:curtains-closed', 'mdi:curtains'],
window: ['mdi:window-closed', 'mdi:window-open'],
awning: ['mdi:awning-outline', 'mdi:awning'],
door: ['mdi:door-closed', 'mdi:door-open'],
garage: ['mdi:garage', 'mdi:garage-open'],
gate: ['mdi:gate', 'mdi:gate-open'],
damper: ['mdi:circle-slice-8', 'mdi:circle-outline'],
};
/**
* Extra [closed, open] pairs recognised on the BASE icon only (owner's
* contract 2026-08-04: for a cover the morph is the ONLY open/closed signal,
* so it must not fall silent on the icons the card itself hands out). These
* are never picked by device_class — they only let a cover whose class is
* missing (z2m ships plenty) or whose icon the user chose by hand still swap
* within its OWN icon family. `mdi:roller-shade` is what the name rule
* «штор|curtain|blind|shade» gives every curtain, `mdi:garage-variant` what
* «ворота|garage|gate» gives every gate.
*/
const COVER_ICON_ALIASES: [string, string][] = [
['mdi:roller-shade-closed', 'mdi:roller-shade'],
['mdi:blinds-horizontal-closed', 'mdi:blinds-horizontal'],
['mdi:garage-variant', 'mdi:garage-open-variant'],
['mdi:door', 'mdi:door-open'],
];
/** Every [closed, open] pair a cover's BASE icon may be recognised by. */
function coverPairs(): [string, string][] {
return [...Object.values(COVER_ICONS), ...COVER_ICON_ALIASES];
}
/** The pair a cover icon belongs to, or null when it is not a known one. */
function coverPairOf(base: string): [string, string] | null {
for (const pair of coverPairs()) {
if (base === pair[0] || base === pair[1]) return pair;
}
return null;
}
/**
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
* HA does. Conservative: only well-known pairs, only when the user has NOT set
* a custom icon, and unknown/unavailable states keep the base icon.
*/
export function stateIcon(
base: string,
domain: string | null | undefined,
deviceClass: string | null | undefined,
state: string | null | undefined,
hasCustomIcon: boolean,
): string {
if (!state || state === 'unavailable' || state === 'unknown') return base;
if (hasCustomIcon) {
// A hand-picked icon normally wins outright. The ONE exception is a cover:
// its plate is neutral in every state (owner 2026-08-04), so the morph is
// all it has — and morphing WITHIN the very pair the user picked from
// (mdi:curtains -> mdi:curtains-closed) shows the state without ever
// trading their icon for a different family.
const pair = domain === 'cover' ? coverPairOf(base) : null;
if (!pair) return base;
return state === 'closed' ? pair[0] : pair[1];
}
if (domain === 'binary_sensor') {
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
}
if (domain === 'cover') {
const pair = COVER_ICONS[String(deviceClass || '')];
if (pair) return state === 'closed' ? pair[0] : pair[1];
// no device_class: morph only when the base icon IS one of the known
// pairs, so a hand-picked auto icon is never swapped for a guess.
// «Closed» is the only state that shows the closed icon: open, ajar
// (HA reports plain 'open' with a position) and both travelling states
// all read as open, exactly like the classed branch above.
const own = coverPairOf(base);
if (own) return state === 'closed' ? own[0] : own[1];
return base;
}
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
return base;
}
// ---------------- light color & alarm states ----------------
/**
* The current color of a light entity as a CSS color, or null when it is off,
* unavailable or reports no usable color. rgb_color is the source of truth
* (HA normalizes hs/xy into it); brightness is deliberately ignored — a dim
* red bulb should still read as red on the plan.
*/
export function lightColorOf(state: any): string | null {
if (!state || state.state !== 'on') return null;
return generatedRgbColor(state.attributes?.rgb_color);
}
// ---------------- independent light-source Glow overlay ----------------
/** Blackbody color temperature → RGB (Tanner Helland approximation). */
export function kelvinToRgb(kelvin: number): [number, number, number] {
const t = Math.min(40000, Math.max(1000, kelvin)) / 100;
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
const g = t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
const cl = (v: number) => Math.round(Math.min(255, Math.max(0, v)));
return [cl(r), cl(g), cl(b)];
}
export interface GlowColorOverride { c: string; bri?: number | null }
export interface ResolvedGlowValues { c: string; bri: number }
export const GLOW_SCALE_MAX = 0.7;
export const GLOW_MIN_FRAC = 0.4;
export const GLOW_GAMMA = 1 / 2.2;
function rgbCssToHex(value: string): string | null {
const match = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(value);
if (!match) return null;
return '#' + match.slice(1).map((part) => Math.min(255, Number(part)).toString(16).padStart(2, '0')).join('');
}
function rgbTupleToHex(value: readonly number[]): string {
return '#' + value.slice(0, 3).map((part) =>
Math.min(255, Math.max(0, Math.round(Number(part) || 0))).toString(16).padStart(2, '0')).join('');
}
/** Strict, all-or-nothing parser for the persisted Glow override. */
export function normalizeGlowColorOverride(value: unknown): GlowColorOverride | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => key !== 'c' && key !== 'bri')) return null;
const c = safeStoredColor(raw.c, null);
if (!c) return null;
if (raw.bri === undefined || raw.bri === null) return { c };
if (typeof raw.bri !== 'number' || !Number.isFinite(raw.bri) || raw.bri < 0.01 || raw.bri > 1) return null;
return { c, bri: raw.bri };
}
/** Live HA brightness normalized to 0..1. Missing/invalid means full output. */
export function liveGlowBrightness(state: any): number {
const attr = state?.attributes?.brightness;
const raw = typeof attr === 'number'
? attr
: (typeof attr === 'string' && attr.trim() !== '' ? Number(attr) : Number.NaN);
return Number.isFinite(raw) ? Math.max(0, Math.min(1, raw / 255)) : 1;
}
/** Resolve colour and brightness without applying the entity's on/off gate. */
export function resolveGlowValues(
state: any,
override: unknown,
fallback: string,
): ResolvedGlowValues {
const normalized = normalizeGlowColorOverride(override);
const a = state?.attributes || {};
const bri = normalized?.bri ?? liveGlowBrightness(state);
if (normalized) return { c: normalized.c, bri };
const rgb = generatedRgbColor(a.rgb_color);
if (rgb) return { c: rgbCssToHex(rgb) || safeStoredColor(fallback, '#ffd9a0'), bri };
const kelvin = Number(a.color_temp_kelvin) || (Number(a.color_temp) > 0 ? 1e6 / Number(a.color_temp) : NaN);
if (Number.isFinite(kelvin) && kelvin > 0) {
return { c: rgbTupleToHex(kelvinToRgb(kelvin)), bri };
}
return { c: safeStoredColor(fallback, '#ffd9a0'), bri };
}
/** Resolve the visible Glow. Off/unavailable/missing state is not painted. */
export function resolveGlowAppearance(
state: any,
override: unknown,
fallback: string,
): ResolvedGlowValues | null {
return state?.state === 'on' ? resolveGlowValues(state, override, fallback) : null;
}
/** Final per-stop opacity; the SVG layer itself must not apply another alpha. */
export function glowAlpha(brightness: number, paletteAlpha = 1): number {
const bri = Math.max(0, Math.min(1, Number.isFinite(brightness) ? brightness : 1));
const palette = Math.max(0, Math.min(1, Number.isFinite(paletteAlpha) ? paletteAlpha : 1));
const alpha = palette * GLOW_SCALE_MAX * (GLOW_MIN_FRAC + (1 - GLOW_MIN_FRAC) * Math.pow(bri, GLOW_GAMMA));
return Math.max(0, Math.min(1, alpha));
}
/** @deprecated Use resolveGlowAppearance(). */
export function glowColorOf(state: any, fallback: string): ResolvedGlowValues | null {
return resolveGlowAppearance(state, null, fallback);
}
/**
* Group toggle for a switch's controlled entities, HA-group semantics:
* any target on -> turn everything off; all off -> turn everything on.
*/
export function controlsAction(states: (string | undefined)[]): 'turn_on' | 'turn_off' {
return states.some((st) => st === 'on') ? 'turn_off' : 'turn_on';
}
/** Only lights and plain switches may be group-controlled from the plan. */
export function isControllable(entityId: string): boolean {
return entityId.startsWith('light.') || entityId.startsWith('switch.');
}
// ---------------- open (virtual) boundaries ----------------
/**
* Collinear overlapping stretches of two room outlines — their shared
* boundary. Handles the real-house case where neighbouring walls only
* PARTIALLY overlap (collinear, different lengths). Returns segments
* [x1,y1,x2,y2] with length > eps.
*/
export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number[][] {
const res: number[][] = [];
if (!a || !b || a.length < 3 || b.length < 3) return res;
for (let i = 0; i < a.length; i++) {
const p1 = a[i], p2 = a[(i + 1) % a.length];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
for (let j = 0; j < b.length; j++) {
const q1 = b[j], q2 = b[(j + 1) % b.length];
// both q endpoints must lie on the line of p1-p2
const d1 = Math.abs((q1[0] - p1[0]) * uy - (q1[1] - p1[1]) * ux);
const d2 = Math.abs((q2[0] - p1[0]) * uy - (q2[1] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
// overlap of parameter intervals along the line
const t1 = (q1[0] - p1[0]) * ux + (q1[1] - p1[1]) * uy;
const t2 = (q2[0] - p1[0]) * ux + (q2[1] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) {
res.push([p1[0] + ux * lo, p1[1] + uy * lo, p1[0] + ux * hi, p1[1] + uy * hi]);
}
}
}
return res;
}
/**
* Segments with the given collinear stretches removed — the workhorse behind
* TRUE dashed open boundaries (derived walls and room outlines alike).
*/
export function cutSegments(segs: number[][], cuts: number[][], eps = 1e-6): number[][] {
const out: number[][] = [];
for (const seg of segs) {
const p1 = [seg[0], seg[1]], p2 = [seg[2], seg[3]];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
// collect cut intervals on this edge
const iv: [number, number][] = [];
for (const c of cuts) {
const d1 = Math.abs((c[0] - p1[0]) * uy - (c[1] - p1[1]) * ux);
const d2 = Math.abs((c[2] - p1[0]) * uy - (c[3] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
const t1 = (c[0] - p1[0]) * ux + (c[1] - p1[1]) * uy;
const t2 = (c[2] - p1[0]) * ux + (c[3] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) iv.push([lo, hi]);
}
if (!iv.length) {
out.push([p1[0], p1[1], p2[0], p2[1]]);
continue;
}
iv.sort((a, b) => a[0] - b[0]);
let cur = 0;
for (const [lo, hi] of iv) {
if (lo - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p1[0] + ux * lo, p1[1] + uy * lo]);
cur = Math.max(cur, hi);
}
if (len - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p2[0], p2[1]]);
}
return out;
}
/** Room outline pieces with the given collinear stretches removed. */
export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6): number[][] {
const edges: number[][] = [];
for (let i = 0; i < poly.length; i++) {
const p1 = poly[i], p2 = poly[(i + 1) % poly.length];
edges.push([p1[0], p1[1], p2[0], p2[1]]);
}
return cutSegments(edges, cuts, eps);
}
/**
* Legacy static URLs (/houseplan_files/plans|files/...) are rewritten to the
* authenticated content endpoint (audit B1). Applied on READ, so stored
* configs keep working without a migration.
*/
/**
* How many paths one `houseplan/content/sign` call may carry. The backend caps
* the request at the same number and silently ignores the rest, so a client
* that sends more gets a partial answer with no way to tell which paths were
* dropped — on a wall tablet those entries then expire for good (review R2-2).
* Keep in sync with MAX_SIGN_PATHS in custom_components/houseplan/const.py.
*/
export const MAX_SIGN_PATHS = 200;
/** A signature is valid for 24 h; refresh once two thirds of it is gone. */
export const SIGN_TTL_MS = 24 * 3600 * 1000;
export const SIGN_REFRESH_MS = 16 * 3600 * 1000;
/** Split a list into chunks of at most `size` (used for signing batches). */
export function chunk<T>(items: T[], size: number): T[][] {
const n = Math.max(1, Math.floor(size));
const out: T[][] = [];
for (let i = 0; i < items.length; i += n) out.push(items.slice(i, i + n));
return out;
}
/**
* Every content url the given config still refers to, normalised through
* `contentUrl`. The signature cache is pruned to this set: without it the cache
* only grows — replaced plans and deleted attachments keep their entries, and
* the total can cross the per-request cap even when the live config is small.
*/
export function referencedContentUrls(cfg: any): Set<string> {
const out = new Set<string>();
const add = (u: unknown) => {
if (typeof u !== 'string' || !u) return;
const c = contentUrl(u);
if (c.startsWith('/api/houseplan/content/')) out.add(c);
};
for (const sp of cfg?.spaces || []) {
add(sp?.plan_url);
for (const m of sp?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
}
for (const m of cfg?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
return out;
}
export function contentUrl(url: string | null | undefined): string {
if (!url) return '';
if (url.startsWith('/houseplan_files/plans/')) {
return '/api/houseplan/content/plans/_/' + url.slice('/houseplan_files/plans/'.length);
}
if (url.startsWith('/houseplan_files/files/')) {
return '/api/houseplan/content/files/' + url.slice('/houseplan_files/files/'.length);
}
return url;
}
// ---------------- room-level settings (tier 3) ----------------
/**
* 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. 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 === 'custom'
? o : spaceFill;
}
/** 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 ----------------
/**
* Rewrite attached-file urls when a marker's id changes (rebinding): the
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
*/
export function migratePdfUrls<T extends { url: string }>(
pdfs: T[], oldId: string, newId: string, mapping?: Record<string, string>,
): T[] {
if (!oldId || !newId || oldId === newId) return pdfs;
const from = '/files/' + oldId + '/';
const to = '/files/' + newId + '/';
return pdfs.map((p) => {
if (!p.url.includes(from)) return p;
const tail = p.url.split(from)[1] || '';
const [name, query] = [tail.split('?')[0], tail.includes('?') ? '?' + tail.split('?')[1] : ''];
if (mapping) {
// review CR-3: rewrite ONLY files the server confirmed it copied, and use
// the name it actually wrote (collisions get a unique name). A url that
// was not copied keeps pointing at the still-existing old folder.
const dst = mapping[decodeURIComponent(name)] ?? mapping[name];
if (!dst) return p;
return { ...p, url: p.url.split(from + name)[0] + to + encodeURIComponent(dst) + query };
}
return { ...p, url: p.url.split(from).join(to) };
});
}
// ---------------- kiosk gestures ----------------
/**
* Kiosk swipe: which neighbouring space a horizontal gesture selects.
* Only fires at 1:1 zoom (owner's decision — when zoomed the gesture pans),
* needs a mostly-horizontal move of at least minPx. Wraps around.
*/
export function swipeTarget(
dx: number, dy: number, zoom: number, spaceIds: string[], current: string, minPx = 60,
): string | null {
if (zoom > 1.001 || spaceIds.length < 2) return null;
if (Math.abs(dx) < minPx || Math.abs(dx) < Math.abs(dy) * 1.5) return null;
const i = spaceIds.indexOf(current);
if (i < 0) return null;
const n = spaceIds.length;
return dx < 0 ? spaceIds[(i + 1) % n] : spaceIds[(i - 1 + n) % n];
}
/** Clamp a per-screen size multiplier (icons / room-card font). */
export function clampScale(v: unknown, def = 1): number {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.min(3, Math.max(0.5, n)) : def;
}
// ---------------- alignment guides ----------------
export interface AlignGuide {
axis: 'x' | 'y';
/** The candidate's aligned coordinate (x for axis x, y for axis y). */
at: number;
/** The candidate point the guide is drawn from. */
from: number[];
}
/**
* Alignment guides for a point being drawn/dragged: the nearest candidate
* sharing its X and the nearest sharing its Y (within tol). Indication only —
* no magnetism, the grid owns the actual position (owner's decision).
*/
export function alignGuides(pt: number[], candidates: number[][], tol: number): AlignGuide[] {
let bestX: { d: number; c: number[] } | null = null;
let bestY: { d: number; c: number[] } | null = null;
for (const c of candidates) {
const same = Math.abs(c[0] - pt[0]) < 1e-6 && Math.abs(c[1] - pt[1]) < 1e-6;
if (same) continue;
if (Math.abs(c[0] - pt[0]) <= tol) {
const d = Math.abs(c[1] - pt[1]);
if (d > 1e-6 && (!bestX || d < bestX.d)) bestX = { d, c };
}
if (Math.abs(c[1] - pt[1]) <= tol) {
const d = Math.abs(c[0] - pt[0]);
if (d > 1e-6 && (!bestY || d < bestY.d)) bestY = { d, c };
}
}
const out: AlignGuide[] = [];
if (bestX) out.push({ axis: 'x', at: bestX.c[0], from: bestX.c });
if (bestY) out.push({ axis: 'y', at: bestY.c[1], from: bestY.c });
return out;
}
/** Segment angle in degrees, normalized to [0, 360). */
export function segmentAngle(a: number[], b: number[]): number {
let deg = (Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI;
if (deg < 0) deg += 360;
return deg;
}
/** Is the angle a multiple of 45° (within tolerance)? */
export function is45(deg: number, tol = 0.5): boolean {
const m = ((deg % 45) + 45) % 45;
return m <= tol || 45 - m <= tol;
}
/** True only when the actual segment vector is an exact octant direction. */
export function isExact45Vector(
a: readonly number[], b: readonly number[], epsilon = 0.001,
): boolean {
if (a.length < 2 || b.length < 2) return false;
const dx = Math.abs(b[0] - a[0]);
const dy = Math.abs(b[1] - a[1]);
if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) <= epsilon) return false;
const scale = Math.max(dx, dy, 1);
return dx <= epsilon * scale || dy <= epsilon * scale || Math.abs(dx - dy) <= epsilon * scale;
}
/** Distance from a point to a segment [x1,y1,x2,y2]. */
export function distToSegment(p: number[], s: number[]): number {
const dx = s[2] - s[0], dy = s[3] - s[1];
const len2 = dx * dx + dy * dy;
if (!len2) return Math.hypot(p[0] - s[0], p[1] - s[1]);
let t = ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (s[0] + t * dx), p[1] - (s[1] + t * dy));
}
/** Device classes whose active state is an emergency, not a status. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
/** Whether an entity can produce House Plan's critical alarm presentation,
* independent of its current state. Used for the static-icon safety notice. */
export function isAlarmCapable(
domain: string | null | undefined,
deviceClass: string | null | undefined,
): boolean {
if (domain === 'alarm_control_panel' || domain === 'siren') return true;
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
}
/**
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, a
* siren that is on, or an alarm panel in `triggered`. Unavailable/unknown
* never alarm (an outage is not a fire).
*/
export function isAlarmState(
domain: string | null | undefined,
deviceClass: string | null | undefined,
state: string | null | undefined,
): boolean {
if (domain === 'alarm_control_panel') return state === 'triggered';
if (state !== 'on') return false;
return isAlarmCapable(domain, deviceClass);
}
// ---------------- room references ----------------
/**
* Parse a marker's room reference. Two shapes:
* - `space#area` — a room bound to an HA area (historical form)
* - `space#@roomId` — a room WITHOUT an area (sub-area rooms, issue #3):
* devices and their room-aware data are assigned manually, by room id.
*/
export function parseRoomRef(
v: string | null | undefined,
): { space: string; area: string | null; roomId: string | null } | null {
if (!v) return null;
const i = v.indexOf('#');
if (i <= 0) return null;
const space = v.slice(0, i);
const rest = v.slice(i + 1);
if (!rest) return null;
if (rest.startsWith('@')) {
const roomId = rest.slice(1);
return roomId ? { space, area: null, roomId } : null;
}
return { space, area: rest, roomId: null };
}
// ---------------- new-device detection ----------------
/**
* Which auto-appearing device ids are NEW against the known baseline.
* No baseline yet (first run / upgrade) → nothing is new: every current id
* becomes the baseline silently, so an update never floods the plan with dots.
*/
export function diffNewDevices(
currentIds: string[],
known: string[] | null | undefined,
): { fresh: string[]; known: string[] } {
if (!Array.isArray(known)) return { fresh: [], known: [...currentIds] };
const knownSet = new Set(known);
const fresh = currentIds.filter((id) => !knownSet.has(id));
return { fresh, known: fresh.length ? [...known, ...fresh] : known };
}