mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
- 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
2162 lines
90 KiB
TypeScript
2162 lines
90 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|