mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
Two owner corrections after the infinite canvas. - --icon-size goes back to being a percentage of the PLAN: a marker grows and shrinks with the zoom, like everything else drawn on the plan. The infinite canvas had made it a percentage of the viewport (fixed pixel size) — the owner looked at it and asked for the original contract back. What survives from the canvas work is the NUMERATOR. The old expression divided by `vb.w`, the stored view_box, which is not a frame any more; a fixed NORM_W in its place would have shrunk every marker on a plan drawn past the old square by exactly the factor the plan is outsized (an invisible dot 50 canvases out). So it is now `iconCqw() = iconPct * iconUnit(space) * kioskScale / view.w`, one pure helper both renderers call. `iconUnit` is exactly NORM_W for any plan that fits the old square — and the editor has never written anything but `view_box: [0,0,1,1]` — so the rendered size is bit-identical to the pre-canvas card: measured against the v1.56.0 bundle at a fixed view, both give 3.400 / 3.091 / 6.182 / 12.364 cqw = 28.52 / 26.11 / 50.22 / 98.44 px. On a plan drawn at 1.5..3.8 the marker is 26.1 px, the same as on an ordinary plan, instead of the ~11 px a fixed numerator would have given. The static space-card uses the same helper: it has no zoom, but its frame is the content now, so a bare iconPct shrank its markers as the frame tightened. marker.size, the kiosk scales and every satellite still ride on --dev-size, untouched. - the icon angle in the device dialog steps by 5 degrees, not 10 (0..355): a marker often has to line up with a wall that is not on a 10-degree grid. Tests: three unit tests on iconCqw (the legacy expression reproduced digit for digit, the runaway plan, the no-view fallback); the infinite canvas smoke's "same pixel size at zoom 1/4/1/3" assert is turned back into "scales 4x / 1/3 with the zoom" plus a new one that the marker on the far plan measures the same as on an ordinary one; the angle step is pinned in smoke_size_angle_parity. docs/CANVAS.md §6 rewritten.
420 lines
18 KiB
TypeScript
420 lines
18 KiB
TypeScript
/**
|
||
* Pure geometry/model helpers for houseplan spaces — no Lit/DOM imports, so they
|
||
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
|
||
* mirror the full card's private math.
|
||
*/
|
||
import { declump, contentUrl } from './logic';
|
||
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
||
|
||
export const NORM_W = 1000; // side of the render space — the canvas is square
|
||
|
||
/**
|
||
* Where a plan image sits inside the square canvas (v1.48.0).
|
||
*
|
||
* The canvas has no proportions of its own any more; the image keeps its own
|
||
* and is centred, so a wide plan gets margins above and below and a tall one
|
||
* gets them at the sides. `ratio` is the image's width/height; without it we
|
||
* assume square, which is only ever a brief guess before the file loads.
|
||
*/
|
||
export function fitInSquare(ratio: number | null | undefined, side: number) {
|
||
const r = Number(ratio);
|
||
const a = Number.isFinite(r) && r > 0 ? r : 1;
|
||
const w = a >= 1 ? side : side * a;
|
||
const h = a >= 1 ? side / a : side;
|
||
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
|
||
}
|
||
|
||
export type Pt = { x: number; y: number };
|
||
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
|
||
|
||
/** Build render-space models (NORM_W × NORM_W) from a server config. */
|
||
/** A stored view_box the render can trust: 4 finite numbers, positive sizes.
|
||
* The server refuses anything else NOW (HP-1502-01), but a store may already
|
||
* hold [0,0,0,0] or negative sizes from before — and a zero axis serialises
|
||
* into viewBox="0 0 0 0", which draws nothing on every client. Bad input
|
||
* falls back to the whole canvas rather than to a blank screen. */
|
||
function safeViewBox(vb: any): [number, number, number, number] {
|
||
if (
|
||
Array.isArray(vb) && vb.length === 4 && vb.every((n: any) => Number.isFinite(n))
|
||
&& vb[2] > 1e-6 && vb[3] > 1e-6
|
||
) return vb as [number, number, number, number];
|
||
return [0, 0, 1, 1];
|
||
}
|
||
|
||
/** Legacy rectangle rooms, normalised: a negative size is the same rectangle
|
||
* drawn from the other corner; the maths downstream assumes w/h >= 0. */
|
||
function normRect(r: any): { x?: number; y?: number; w?: number; h?: number } {
|
||
if (r.x == null || r.y == null) return { x: r.x, y: r.y, w: r.w, h: r.h };
|
||
const w = Number(r.w) || 0, h = Number(r.h) || 0;
|
||
return {
|
||
x: w < 0 ? r.x + w : r.x,
|
||
y: h < 0 ? r.y + h : r.y,
|
||
w: Math.abs(w),
|
||
h: Math.abs(h),
|
||
};
|
||
}
|
||
|
||
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||
if (!cfg || !Array.isArray(cfg.spaces)) return [];
|
||
return cfg.spaces.map((s: any) => {
|
||
const H = NORM_W; // square canvas
|
||
const scale = (raw: any): RoomCfg => { const r = { ...raw, ...normRect(raw) }; return {
|
||
id: r.id,
|
||
name: r.name,
|
||
area: r.area ?? null,
|
||
// carried, not dropped: the static card renders from this model too, and
|
||
// without them it ignored the room-level fill override and drew a room the
|
||
// full card leaves transparent (HP-1454-07)
|
||
open_to: r.open_to || undefined,
|
||
settings: r.settings || undefined,
|
||
x: r.x != null ? r.x * NORM_W : undefined,
|
||
y: r.y != null ? r.y * H : undefined,
|
||
w: r.w != null ? r.w * NORM_W : undefined,
|
||
h: r.h != null ? r.h * H : undefined,
|
||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||
}; };
|
||
const vb = safeViewBox(s.view_box);
|
||
return {
|
||
id: s.id,
|
||
title: s.title,
|
||
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
|
||
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||
rooms: (s.rooms || []).map(scale),
|
||
} as SpaceModel;
|
||
});
|
||
}
|
||
|
||
/* =====================================================================
|
||
* INFINITE CANVAS (docs/CANVAS.md)
|
||
* ===================================================================== */
|
||
|
||
/** Sane coordinate range in NORMALISED units — mirrors validation.py.
|
||
* Not a frame: insurance against a stored 1e100 (HP-1500-03/HP-1501-01). */
|
||
export const CANVAS_LIMIT = 5000;
|
||
/** The same range in RENDER units. */
|
||
export const SANE_LIMIT = CANVAS_LIMIT * NORM_W;
|
||
|
||
/** Zoom-out floor: three times the content frame and no further (CANVAS.md §5). */
|
||
export const MIN_ZOOM = 1 / 3;
|
||
/** How far past the content frame panning may go, in screens (CANVAS.md §5). */
|
||
export const PAN_SLACK = 1;
|
||
|
||
/** Outlier vote tuning — see docs/CANVAS.md §4.1. */
|
||
export const OUTLIER_K = 10; // "an order of magnitude further"
|
||
export const MIN_VOTERS = 4; // fewer items: nobody to be far FROM
|
||
export const MIN_SPREAD = NORM_W * 0.05; // ~a small room: floor for the scale
|
||
export const OUTLIER_MAX_SHARE = 1 / 3; // more than this: not strays, a wide plan
|
||
|
||
/** A degenerate axis (a lone marker) is grown to this, so the SVG paints. */
|
||
export const DEGENERATE = NORM_W * 0.03;
|
||
export const FLOOR = NORM_W * 0.2;
|
||
|
||
export type Rect = { x: number; y: number; w: number; h: number };
|
||
/** One drawn/placed object, as its own bounding box (render units). */
|
||
export type ContentItem = { minX: number; minY: number; maxX: number; maxY: number };
|
||
|
||
/** Bounding box of a point cloud, as a ContentItem. */
|
||
export function itemOf(pts: ReadonlyArray<readonly [number, number] | number[]>): ContentItem | null {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const p of pts) {
|
||
const x = Number(p[0]), y = Number(p[1]);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||
if (x < minX) minX = x;
|
||
if (y < minY) minY = y;
|
||
if (x > maxX) maxX = x;
|
||
if (y > maxY) maxY = y;
|
||
}
|
||
return minX > maxX ? null : { minX, minY, maxX, maxY };
|
||
}
|
||
|
||
/** The room's own bounding box as a content item (polygon or legacy rect). */
|
||
export function roomItem(r: RoomCfg): ContentItem | null {
|
||
if (r.poly && r.poly.length) return itemOf(r.poly);
|
||
if (r.x == null || r.y == null) return null;
|
||
return itemOf([[r.x, r.y], [r.x + (r.w || 0), r.y + (r.h || 0)]]);
|
||
}
|
||
|
||
/**
|
||
* Every object of a space that counts as content (docs/CANVAS.md §4):
|
||
* the rooms, the backdrop image rectangle, plus whatever the caller adds
|
||
* (devices, openings, decor — the model does not carry those).
|
||
*/
|
||
export function contentItems(
|
||
space: SpaceModel,
|
||
extra?: ReadonlyArray<ContentItem | readonly [number, number]>,
|
||
): ContentItem[] {
|
||
const out: ContentItem[] = [];
|
||
for (const r of space.rooms || []) { const it = roomItem(r); if (it) out.push(it); }
|
||
// With a backdrop the IMAGE sets the extent: cropping to the outlined rooms
|
||
// would hide the parts of the picture nobody has drawn over yet.
|
||
if (space.bg) out.push({ minX: space.bg.x, minY: space.bg.y, maxX: space.bg.x + space.bg.w, maxY: space.bg.y + space.bg.h });
|
||
for (const e of extra || []) {
|
||
if (Array.isArray(e)) { const it = itemOf([e as any]); if (it) out.push(it); }
|
||
else out.push(e as ContentItem);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const median = (a: number[]): number => {
|
||
if (!a.length) return 0;
|
||
const s = [...a].sort((p, q) => p - q);
|
||
const m = s.length >> 1;
|
||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||
};
|
||
|
||
/** Rank-based quantile of an ALREADY SORTED array (nearest-rank, clamped). */
|
||
const quantile = (sorted: number[], q: number): number => {
|
||
if (!sorted.length) return 0;
|
||
const i = Math.min(sorted.length - 1, Math.max(0, Math.round(q * (sorted.length - 1))));
|
||
return sorted[i];
|
||
};
|
||
|
||
const boxOf = (items: ContentItem[]): Rect | null => {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const it of items) {
|
||
if (it.minX < minX) minX = it.minX;
|
||
if (it.minY < minY) minY = it.minY;
|
||
if (it.maxX > maxX) maxX = it.maxX;
|
||
if (it.maxY > maxY) maxY = it.maxY;
|
||
}
|
||
if (minX > maxX || minY > maxY) return null;
|
||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||
};
|
||
|
||
/** Pad by `pad` of the longer side and lift a degenerate axis off zero. */
|
||
function padRect(b: Rect, pad: number): Rect {
|
||
let { x, y, w, h } = b;
|
||
// A single marker (or a collinear row of them) has no area, and an SVG
|
||
// viewBox with a zero axis draws nothing at all (HP-1500-03). Only the
|
||
// DEGENERATE case is inflated — a real 100-unit corridor keeps its frame.
|
||
if (w < DEGENERATE) { x = x + w / 2 - FLOOR / 2; w = FLOOR; }
|
||
if (h < DEGENERATE) { y = y + h / 2 - FLOOR / 2; h = FLOOR; }
|
||
const m = Math.max(w, h) * pad;
|
||
return { x: x - m, y: y - m, w: w + m * 2, h: h + m * 2 };
|
||
}
|
||
|
||
export interface ContentFrame {
|
||
/** The main mass, padded — the opening view. */
|
||
core: Rect | null;
|
||
/** Everything, padded — what "show the far objects" fits. */
|
||
all: Rect | null;
|
||
/** How many items the core deliberately leaves out. */
|
||
outliers: number;
|
||
}
|
||
|
||
/**
|
||
* The content frame (docs/CANVAS.md §4). Pure and rank-based: one absurd
|
||
* coordinate can move neither the median nor the 75th percentile, so it
|
||
* cannot decide the frame — but it is never DELETED either, it is simply
|
||
* not a voter, and "show everything" still reaches it.
|
||
*/
|
||
export function contentFrame(
|
||
items: ReadonlyArray<ContentItem>,
|
||
opts: { pad?: number; k?: number; minSpread?: number } = {},
|
||
): ContentFrame {
|
||
const pad = opts.pad ?? 0.05;
|
||
const k = opts.k ?? OUTLIER_K;
|
||
const minSpread = opts.minSpread ?? MIN_SPREAD;
|
||
// Step 1 — corruption, not content: anything past the range the backend
|
||
// itself accepts is dropped outright (it is not reachable by "show all"
|
||
// either, because it is not a coordinate anybody meant).
|
||
const sane = items.filter((it) =>
|
||
Number.isFinite(it.minX) && Number.isFinite(it.minY)
|
||
&& Number.isFinite(it.maxX) && Number.isFinite(it.maxY)
|
||
&& Math.abs(it.minX) <= SANE_LIMIT && Math.abs(it.maxX) <= SANE_LIMIT
|
||
&& Math.abs(it.minY) <= SANE_LIMIT && Math.abs(it.maxY) <= SANE_LIMIT);
|
||
if (!sane.length) return { core: null, all: null, outliers: 0 };
|
||
const all = boxOf(sane)!;
|
||
if (sane.length < MIN_VOTERS) {
|
||
const r = padRect(all, pad);
|
||
return { core: r, all: r, outliers: 0 };
|
||
}
|
||
const cx = sane.map((it) => (it.minX + it.maxX) / 2);
|
||
const cy = sane.map((it) => (it.minY + it.maxY) / 2);
|
||
const mx = median(cx), my = median(cy);
|
||
const d = sane.map((_, i) => Math.max(Math.abs(cx[i] - mx), Math.abs(cy[i] - my)));
|
||
const spread = Math.max(quantile([...d].sort((p, q) => p - q), 0.75), minSpread);
|
||
const far = d.map((v) => v > k * spread);
|
||
const n = far.filter(Boolean).length;
|
||
// Majority veto: a plan whose objects are simply spread out has no strays.
|
||
const keep = n && n <= sane.length * OUTLIER_MAX_SHARE ? sane.filter((_, i) => !far[i]) : sane;
|
||
const core = boxOf(keep) || all;
|
||
return {
|
||
core: padRect(core, pad),
|
||
all: padRect(all, pad),
|
||
outliers: keep === sane ? 0 : n,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* What the plan actually occupies, padded by `pad` of the larger side —
|
||
* the MAIN MASS only (docs/CANVAS.md §4.1). Returns null when nothing is
|
||
* drawn, so the caller can fall back to the stored view_box hint.
|
||
*
|
||
* Kept as the narrow entry point used by the renderers; `contentFrame`
|
||
* is the one that also reports the outliers and the fit-everything box.
|
||
*/
|
||
export function contentBounds(
|
||
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
|
||
): Rect | null {
|
||
return contentFrame(contentItems(space, extra), { pad }).core;
|
||
}
|
||
|
||
/** The rectangle a renderer frames a space with: content, else the stored
|
||
* view_box HINT, else the legacy unit square (docs/CANVAS.md §4). */
|
||
export function spaceFrame(
|
||
space: SpaceModel,
|
||
extra?: ReadonlyArray<ContentItem | readonly [number, number]>,
|
||
pad = 0.05,
|
||
): Rect {
|
||
const f = contentFrame(contentItems(space, extra), { pad });
|
||
if (f.core) return f.core;
|
||
const vb = space.vb && space.vb.length === 4 && space.vb[2] > 0 && space.vb[3] > 0
|
||
? space.vb : [0, 0, NORM_W, NORM_W];
|
||
return { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
|
||
}
|
||
|
||
/** Middle of the content (fallback anchor for a device with no position). */
|
||
export function spaceCenter(space: SpaceModel): Pt {
|
||
const r = spaceFrame(space);
|
||
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
|
||
}
|
||
|
||
/**
|
||
* The BASE UNIT one icon-percent is measured against, in render units
|
||
* (docs/CANVAS.md §6). It is both the icon's own footprint and the
|
||
* auto-placement spacing, so the two can never drift apart.
|
||
*
|
||
* For any plan that fits the old square this is exactly NORM_W — which is
|
||
* what `vb.w` was for every plan the card itself ever wrote (the editor only
|
||
* ever stored `view_box: [0,0,1,1]`), so sizes and layouts are bit-identical
|
||
* to the pre-infinite-canvas card. A plan drawn three canvases wide gets a
|
||
* proportionally bigger unit, which is what keeps its markers from
|
||
* degenerating into dots once the frame is the content (see `iconCqw`).
|
||
* Rooms only — deterministic, so the full card and the static card agree.
|
||
*/
|
||
export function iconUnit(space: SpaceModel): number {
|
||
const items: ContentItem[] = [];
|
||
for (const r of space.rooms || []) { const it = roomItem(r); if (it) items.push(it); }
|
||
const b = boxOf(items);
|
||
if (!b) return NORM_W;
|
||
return Math.max(NORM_W, Math.min(SANE_LIMIT, Math.max(b.w, b.h)));
|
||
}
|
||
|
||
/**
|
||
* `--icon-size` in cqw (docs/CANVAS.md §6) — the ONE expression both
|
||
* renderers use.
|
||
*
|
||
* An icon is a percentage of the PLAN, not of the viewport: it scales with
|
||
* the plan as you zoom, exactly as it did before the infinite canvas (owner,
|
||
* 2026-08-03). In render units the marker always occupies
|
||
* `iconPct/100 * iconUnit`, whatever the frame or the zoom happens to be;
|
||
* dividing by the width of the visible view turns that into a percentage of
|
||
* the container, which is what `cqw` means.
|
||
*
|
||
* The only thing the infinite canvas changed here is the numerator: it used
|
||
* to be `vb.w`, the stored view_box, and a plan drawn past the old square
|
||
* kept a 1000-unit numerator while its frame grew to tens of thousands —
|
||
* markers would shrink to invisible dots. `iconUnit` is that same 1000 for
|
||
* an ordinary plan and grows with an outsized one.
|
||
*/
|
||
export function iconCqw(
|
||
iconPct: number, space: SpaceModel, viewW: number, kioskIcon = 1,
|
||
): number {
|
||
const w = Number(viewW);
|
||
const k = Number.isFinite(kioskIcon) && kioskIcon > 0 ? kioskIcon : 1;
|
||
// No view yet (first paint, zero-width stage): fall back to the plain
|
||
// percentage rather than to Infinity/NaN — the frame arrives a tick later.
|
||
if (!Number.isFinite(w) || w <= 0) return iconPct * k;
|
||
return (iconPct * iconUnit(space) * k) / w;
|
||
}
|
||
|
||
/** Grid step multipliers offered to the adaptive grid (docs/CANVAS.md §7). */
|
||
export const GRID_STEPS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
|
||
|
||
/**
|
||
* Adaptive grid density: which multiple of the base pitch is still legible
|
||
* at the current scale, and which coarser one carries the accent dots.
|
||
* `null` = even the coarsest step is sub-pixel, draw no grid at all.
|
||
*/
|
||
export function gridLevels(
|
||
pitch: number, pxPerUnit: number, minPx = 7,
|
||
): { fine: number; coarse: number } | null {
|
||
if (!(pitch > 0) || !(pxPerUnit > 0) || !Number.isFinite(pxPerUnit)) return null;
|
||
const fine = GRID_STEPS.find((m) => pitch * m * pxPerUnit >= minPx);
|
||
if (fine === undefined) return null;
|
||
const coarse = GRID_STEPS.find((m) => m >= fine * 5) ?? fine * 5;
|
||
return { fine, coarse };
|
||
}
|
||
|
||
/** Bounding rectangle of a room (rect or polygon) in render units. */
|
||
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||
if (r.poly && r.poly.length) {
|
||
const xs = r.poly.map((p) => p[0]);
|
||
const ys = r.poly.map((p) => p[1]);
|
||
const x = Math.min(...xs);
|
||
const y = Math.min(...ys);
|
||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||
}
|
||
return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 };
|
||
}
|
||
|
||
/** Geometric centre of a room (label anchor). */
|
||
export function roomCenter(r: RoomCfg): number[] {
|
||
if (r.poly) {
|
||
const n = r.poly.length;
|
||
return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n];
|
||
}
|
||
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
|
||
}
|
||
|
||
/** Auto grid positions for a single space's area devices (identical to the full card). */
|
||
export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: number): Record<string, Pt> {
|
||
const map: Record<string, Pt> = {};
|
||
// NOT a bare NORM_W any more (docs/CANVAS.md §6): on a plan wider than the
|
||
// old square the icons are proportionally larger in render units, so the
|
||
// declump distance has to grow with the plan or auto-placed markers overlap.
|
||
const minDist = (iconPct / 100) * iconUnit(model) * 1.3;
|
||
for (const r of model.rooms) {
|
||
if (!r.area) continue;
|
||
const ds = devs.filter((d) => d.area === r.area);
|
||
if (!ds.length) continue;
|
||
const b = roomBounds(r);
|
||
const pad = Math.min(b.w, b.h) * 0.1;
|
||
const iw = b.w - pad * 2;
|
||
const ih = b.h - pad * 2;
|
||
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
|
||
const cw = iw / cols;
|
||
const ch = ih / Math.max(Math.ceil(ds.length / cols), 1);
|
||
const pts = ds.map((_, i) => ({
|
||
x: b.x + pad + cw * ((i % cols) + 0.5),
|
||
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
|
||
}));
|
||
declump(pts, b, minDist, pad * 0.5);
|
||
ds.forEach((d, i) => (map[d.id] = pts[i]));
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/** Marker position in render units: saved layout → default grid → space centre. */
|
||
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
|
||
const saved = layout[d.id];
|
||
if (saved && saved.s === d.space) {
|
||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||
}
|
||
if (defPos[d.id]) return defPos[d.id];
|
||
// no saved position, no room to auto-place in: the middle of what IS drawn,
|
||
// not the middle of a canvas that no longer has edges (docs/CANVAS.md)
|
||
return spaceCenter(model);
|
||
}
|
||
|
||
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
|
||
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
|
||
const saved = layout['rl_' + (r.id || '')];
|
||
if (saved && saved.s === spaceId) {
|
||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||
}
|
||
const c = roomCenter(r);
|
||
return { x: c[0], y: c[1] };
|
||
}
|