Icons scale with the plan again, and a 5 degree angle step

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.
This commit is contained in:
houseplan-dev
2026-08-04 00:06:21 +03:00
parent 693601a8e0
commit 2c947f4f7a
10 changed files with 291 additions and 103 deletions
File diff suppressed because one or more lines are too long
+49 -6
View File
@@ -1,7 +1,7 @@
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
// renders whole, devices can be placed and saved out there, the opening view
// follows the content, one far stray neither breaks the view nor hides itself,
// zoom-out stops at 3x the content and icons keep their pixel size.
// zoom-out stops at 3x the content and the icons keep their proportion to it.
import { writeFileSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { launch, check, checkAll, finish } from './serve.mjs';
@@ -223,15 +223,25 @@ Object.assign(out, await page.evaluate(async () => {
return o;
}));
// ---- icons keep their PIXEL size across zooms (docs/CANVAS.md §6) --------
out.iconSizeIsViewportRelative = await page.evaluate(async () => {
// ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ----------
// The infinite canvas briefly made --icon-size a percentage of the viewport,
// so a marker kept its pixel size at every zoom. The owner looked at it and
// asked for the original contract back (2026-08-03): an icon is a percentage
// of the PLAN, it grows when you zoom in and shrinks when you zoom out, like
// everything else drawn on the plan. What the infinite canvas contributes is
// only the base unit the percentage is taken OF: `iconUnit` instead of the
// stored `vb.w`, checked right below on this very plan — which is drawn
// entirely outside the old square.
const farIcon = await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the badge is --dev-size PLUS a 1px border on each side, and the border
// is not part of the scaled geometry (same correction as smoke_icon_scale)
const size = () => {
const el = sr.querySelector('.devlayer .dev');
const r = el.getBoundingClientRect();
return Math.round(r.width * 10) / 10;
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
};
const o = {};
c._resetZoom(); await c.updateComplete;
const at1 = size();
c._applyView(4); await c.updateComplete;
@@ -239,8 +249,12 @@ out.iconSizeIsViewportRelative = await page.evaluate(async () => {
c._applyView(1 / 3); await c.updateComplete;
const atOut = size();
c._resetZoom(); await c.updateComplete;
return at1 > 4 && Math.abs(at4 - at1) <= 1 && Math.abs(atOut - at1) <= 1;
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
return { o, at1 };
});
Object.assign(out, farIcon.o);
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
Object.assign(out, await page.evaluate(async () => {
@@ -278,6 +292,35 @@ Object.assign(out, await page.evaluate(async () => {
return o;
}));
// ---- and the icon does not DEGENERATE on the runaway plan ----------------
// The catch of "a percentage of the plan" on an unbounded canvas: keep the
// old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide
// divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor
// and keeps shrinking the further out the plan is drawn. `iconUnit` grows
// with the plan, so the marker on the far plan measured above is the same
// number of pixels as on this ordinary 0..1 one.
out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the small plan from the block above, with its devices back
c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
markers: [], settings: {} };
c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await c.updateComplete;
const el = sr.querySelector('.devlayer .dev');
const px = el.getBoundingClientRect().width - 2;
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
return px > 4 && Math.abs(px / farPx - 1) < 0.3;
}, farIcon.at1);
// ---- screenshots ---------------------------------------------------------
if (SHOTS) {
mkdirSync(SHOTS, { recursive: true });
+30 -1
View File
@@ -58,4 +58,33 @@ const out = await page.evaluate(async () => {
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
checkAll(out);
// Шаг угла в диалоге устройства — 5°, а не 10° (владелец, 2026-08-03):
// значок часто надо выровнять по стене, которая не лежит на сетке в 10°.
const out3 = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => d.space === 'f1');
c._openMarkerDialog(dev); await c.updateComplete;
// строка «размер · угол»: два ползунка, второй — угол
const rows = [...sr().querySelectorAll('.dialog .colorrow')];
const row = rows.find((r) => r.textContent.includes('°'));
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
const maxOf = (el) => Number(el.max ?? el.getAttribute('max'));
o.angleStepIs5 = ctl.length === 2 && stepOf(ctl[1]) === 5;
o.angleReaches355 = maxOf(ctl[1]) === 355;
o.sizeStepUnchanged = stepOf(ctl[0]) === 0.1;
// и значение реально ложится на 5°
ctl[1].value = 35;
ctl[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
o.angle35Applied = c._markerDialog.angle === 35;
c._markerDialog = null; await c.updateComplete;
c._setMode('view'); await c.updateComplete;
return o;
});
await finish(browser, checkAll(out3));
File diff suppressed because one or more lines are too long
+21 -19
View File
File diff suppressed because one or more lines are too long
+44 -23
View File
@@ -20,7 +20,7 @@ coordinate system.
device positions are still stored normalised. `1.0` is still the
same distance it always was; `cell_cm` still ties a grid cell to
real centimetres. **No data migration.** An existing plan opens as
before (the one deliberate exception is icon size — see §6).
before — including the size of the markers (§6).
2. **`0..1` is not a boundary, it is an origin.** Any finite
coordinate is legal. `2.7` simply means "2.7 canvas widths to the
right of the origin".
@@ -39,7 +39,7 @@ coordinate system.
| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) |
| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) |
| Pan bounds | content must cover the scene | content frame + one screen of slack in each direction |
| Icon size | % of `view_box`, i.e. grew with zoom | % of the visible viewport (§6) |
| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) |
| Validation range | `+/-4` | `+/-5000` (§3) |
### Render frame vs. view
@@ -162,39 +162,60 @@ was silently excluded from the frame).
in the frame's direction. Clicking it fits the content. Cheap
insurance against getting lost in the empty plane.
## §6 Icon size — from the viewport
## §6 Icon size — a percentage of the plan
**Changed behaviour, owner is aware.** Before:
**Unchanged behaviour** — an icon scales with the plan, exactly as it
always did. (A first cut of the infinite canvas made it a fixed
percentage of the viewport; the owner looked at it on 2026-08-03 and
asked for the original back. The history is kept here because the
reasoning for the *numerator* below is the whole point.)
Before the infinite canvas:
```
--icon-size: iconPct * vb.w / view.w (cqw)
```
so an icon grew as you zoomed in — at 8x zoom a marker covered a whole
room. Now:
Now (`iconCqw()` in `src/space-geometry.ts`, pure and unit tested):
```
--icon-size: iconPct * kioskScale (cqw)
--icon-size: iconPct * iconUnit(space) * kioskScale / view.w (cqw)
```
An icon is a fixed percentage of the **visible viewport**: zooming
changes how much plan you see, not how big the markers are. Side
effects, all intended:
Read it in render units: a marker always occupies
`iconPct/100 * iconUnit` **render units** of the plan, whatever the
frame and whatever the zoom. Dividing by the width of the visible view
turns that into the percentage of the container `cqw` means. Zoom in
2x and the marker is 2x bigger, together with the walls it sits on.
* the full card and the static `houseplan-space-card` now use the
identical expression — the two renderers finally agree at every zoom;
* the per-device multiplier `marker.size` and the kiosk icon/font
scales are untouched: they still feed `--dev-size`, and every
satellite (badges, LQI chips, presence rings, ripples) still derives
from `--dev-size` exactly as before.
**Why the numerator changed.** `vb.w` was the stored `view_box`, and
`view_box` is not a frame any more (§4). Keeping a fixed `NORM_W`
there would have been worse than wrong: on a plan drawn 2 canvases
wide the frame is ~2.2 canvases, so every marker would come out 2.2x
smaller than on an ordinary plan — and 55x smaller on a plan 50
canvases out, i.e. an invisible dot. `iconUnit(space) =
max(NORM_W, roomsExtent)` is:
* **exactly `NORM_W` for every plan that fits the old square**, and the
editor has only ever stored `view_box: [0,0,1,1]`, so `iconUnit ===
vb.w` there and the rendered pixel size is bit-identical to the
pre-canvas card (verified against the v1.56.0 bundle at a fixed view:
`3.400 / 3.091 / 6.182 / 12.364 cqw`, i.e. `28.52 / 26.11 / 50.22 /
98.44 px`, both bundles);
* **proportional to an outsized plan**, so a runaway plan gets markers
of the same apparent size as an ordinary one.
Everything else is untouched: the per-device multiplier `marker.size`
and the kiosk icon/font scales still feed `--dev-size`, and every
satellite (badges, LQI chips, presence rings, ripples) still derives
from `--dev-size`. The full card and the static
`houseplan-space-card` call the same `iconCqw()` — the static card has
no zoom, but its frame is the content now, so a bare `iconPct` would
have made its markers shrink as the frame tightened.
**Auto-placement spacing** (`defaultPositions` -> `declump`) is measured
in render units, so it needs the icon's render-unit footprint, which is
now frame-relative. `iconUnit(space) = max(NORM_W, roomsExtent)` is
used instead of a bare `NORM_W`: for any plan that fits the old square
this is exactly `NORM_W` (bit-identical placement, no churn), and for a
plan three canvases wide the spacing grows with it. Both renderers call
the same helper, so the static card and the full card stay in step.
in render units and uses the same `iconUnit`, so the icon's footprint
and the distance markers are pushed apart by can never drift apart.
## §7 Adaptive grid
@@ -232,7 +253,7 @@ fits `all`.
| `contentBounds` envelope `-25 %..125 %` | content outside the square does not count | **removed** — replaced by §4.1 outlier rejection |
| `_baseVb()` `if (mode !== 'view') return m.vb` | editors need the whole square to have room to draw | **removed** — the content frame plus §5 pan slack and 3x zoom-out gives more room than the square ever did |
| `_baseVb()` `if (m.bg) return m.vb` | image plans frame on the square | image rect is now just one content item (§4) |
| `--icon-size` scaled by `vb.w / view.w` | icon is a fraction of the canvas | §6 |
| `--icon-size` scaled by `vb.w / view.w` | the canvas is what an icon is a fraction OF | numerator becomes `iconUnit()`; the icon still scales with the plan (§6) |
| `defaultPositions` `minDist` from `NORM_W` | one canvas = one plan | `iconUnit()` (§6) |
| `markerPos` / `_pos` fallback = `view_box` centre | a device with no position belongs in the middle of the square | `spaceCenter()` — the middle of the content |
| grid `<rect>` over `vb` | the grid ends with the square | rect follows the view (§7) |
+14 -9
View File
@@ -51,7 +51,7 @@ import './space-card';
import { cardStyles } from './styles';
import {
fitInSquare, contentBounds, spaceModels, contentFrame, contentItems, spaceFrame,
spaceCenter, iconUnit, gridLevels, itemOf,
spaceCenter, iconUnit, iconCqw, gridLevels, itemOf,
MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, type ContentItem, type Rect,
} from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
@@ -5663,14 +5663,17 @@ class HouseplanCard extends LitElement {
${this._renderOpenings(disp)}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
</svg>
${''/* docs/CANVAS.md §6: --icon-size is a percentage of the VISIBLE
viewport (cqw), no longer of the canvas — the old
`* vb.w / view.w` made a marker grow with the zoom until it
covered a room, and on an unbounded canvas "a percent of the
canvas" has no meaning at all. Same expression as the static
space-card, so the two renderers finally agree. The per-device
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
had, restored by the owner. `iconCqw` is `iconPct * iconUnit
/ view.w`: the old expression with the stored `vb.w` replaced
by the plan's own base unit, which is the same NORM_W for an
ordinary plan (pixel-identical) but grows with a plan drawn
past the old square, where a fixed 1000 would have shrunk
every marker to a dot. Same expression as the static
space-card, so the two renderers agree. The per-device
multiplier and the kiosk scales still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${(iconPct * (this._kiosk ? this._kioskScale.icon : 1)).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))}
${this._renderVacuums(devs, view)}
${this._renderVacFit(view)}
@@ -7553,7 +7556,9 @@ class HouseplanCard extends LitElement {
${this._rangeInput(0.5, 3, 0.1, d.size, (n) => (this._markerDialog = { ...d, size: n }))}
<span class="opv">×${d.size.toFixed(1)}</span>
<span class="opl">${this._t('marker.angle_label')}</span>
${this._rangeInput(0, 350, 10, d.angle, (n) => (this._markerDialog = { ...d, angle: n }))}
${''/* 5 degrees, not 10 (owner 2026-08-03): a marker often has to
line up with a wall that is not on a 10-degree grid. */}
${this._rangeInput(0, 355, 5, d.angle, (n) => (this._markerDialog = { ...d, angle: n }))}
<span class="opv">${d.angle}°</span>
</div>
+38 -4
View File
@@ -281,10 +281,16 @@ export function spaceCenter(space: SpaceModel): Pt {
}
/**
* Render units one icon-percent is measured against for AUTO-PLACEMENT
* spacing (docs/CANVAS.md §6). For any plan that fits the old square this
* is exactly NORM_W, so existing layouts do not move by a hair; a plan
* three canvases wide gets proportionally wider spacing.
* 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 {
@@ -295,6 +301,34 @@ export function iconUnit(space: SpaceModel): number {
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];
+8 -2
View File
@@ -14,7 +14,7 @@ import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, NORM_W,
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
type Layout, type ContentItem,
} from './space-geometry';
@@ -198,7 +198,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: nothing}
${roomShapes}
</svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
${''/* docs/CANVAS.md §6: the same expression as the full card. The
static card has no zoom, but its frame is the CONTENT now, so a
bare `iconPct` would make markers shrink relative to the plan the
tighter the frame got. `iconCqw` keeps the marker's footprint at
iconPct% of the plan's base unit, which is what it was when the
frame was the stored view_box. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw">${markers}${labels}</div>
</div>
`;
}
+45 -1
View File
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import {
NORM_W, CANVAS_LIMIT, SANE_LIMIT, MIN_ZOOM, OUTLIER_K, MIN_VOTERS,
spaceModels, contentItems, contentFrame, contentBounds, spaceFrame, spaceCenter,
iconUnit, gridLevels, itemOf, roomItem, defaultPositions,
iconUnit, iconCqw, gridLevels, itemOf, roomItem, defaultPositions,
} from '../test-build/space-geometry.js';
const model = (space) => spaceModels({ spaces: [{ view_box: [0, 0, 1, 1], rooms: [], ...space }], markers: [] })[0];
@@ -62,6 +62,50 @@ test('a plan past the square keeps its icon spacing in proportion', () => {
}
});
// ------------------------------------------------------------- icon size
test('icon size: a percentage of the PLAN, exactly as before the canvas', () => {
// The pre-infinite-canvas card rendered `--icon-size: iconPct * vb.w / view.w`
// and the editor only ever wrote view_box [0,0,1,1], i.e. vb.w === NORM_W.
// iconCqw must reproduce that number to the last digit on such a plan.
const small = model({ id: 's', rooms: [{ id: 'r', poly: [[0.04, 0.14], [0.96, 0.14], [0.96, 0.86], [0.04, 0.86]] }] });
const legacy = (pct, viewW) => (pct * NORM_W) / viewW;
for (const viewW of [1000, 1100, 1093.969144460028, 550, 275, 137.5]) {
assert.equal(iconCqw(3.4, small, viewW), legacy(3.4, viewW));
}
// and that number IS "scales with the plan": half the view, twice the icon
assert.equal(iconCqw(2.5, small, 500) / iconCqw(2.5, small, 1000), 2);
assert.equal(iconCqw(2.5, small, 8000) / iconCqw(2.5, small, 1000), 0.125);
// the kiosk icon multiplier is a plain factor, as it was
assert.equal(iconCqw(2.5, small, 1000, 2), 2 * iconCqw(2.5, small, 1000));
});
test('icon size: a plan drawn past the old square keeps its markers', () => {
// rooms at 1.0..3.0 — 2 canvases wide, framed by ~2.2 canvases once the
// frame is the content. With the old fixed NORM_W numerator the marker
// would be 2.2x smaller than on an ordinary plan, and 55x smaller on a
// plan 50 canvases wide: the reason the numerator is iconUnit.
const big = model({ id: 's', rooms: [{ id: 'r', poly: [[1, 1], [3, 1], [3, 3], [1, 3]] }] });
const small = model({ id: 's', rooms: [{ id: 'r', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }] });
const frame = (m) => { const f = spaceFrame(m); return f.w; }; // content + 5%
assert.equal(frame(small), NORM_W * 1.1);
assert.equal(frame(big), 2 * NORM_W * 1.1);
// same percentage of the frame on both plans — the marker did not degenerate
assert.equal(iconCqw(2.5, big, frame(big)), iconCqw(2.5, small, frame(small)));
assert.ok(iconCqw(2.5, big, frame(big)) > 2, 'and it is a real size, not a dot');
// the degenerate alternative, for the record
assert.ok((2.5 * NORM_W) / frame(big) < iconCqw(2.5, big, frame(big)) / 1.9);
// the size unit and the auto-placement spacing are the same unit
assert.equal(iconUnit(big), 2 * NORM_W);
});
test('icon size: no view yet is a plain percentage, never NaN', () => {
const m = model({ id: 's', rooms: [{ id: 'r', poly: [[0.1, 0.1], [0.5, 0.5]] }] });
for (const bad of [0, -1, NaN, Infinity, null, undefined]) {
assert.equal(iconCqw(2.5, m, bad), 2.5);
}
assert.equal(iconCqw(2.5, m, 0, 1.5), 3.75);
});
// ------------------------------------------------------------- the outlier
test('an outlier does not command the frame, but "show all" reaches it', () => {
const items = [