feat v1.18.0: live segment ruler while drawing rooms + per-space scale
Validate / hacs (push) Has been cancelled
Validate / hassfest (push) Has been cancelled
Validate / frontend (push) Has been cancelled
Validate / backend (push) Has been cancelled

- Draw mode shows the current segment length near the cursor (metres or feet+inches
  per the HA unit system). New pure helpers segmentCm/formatLength (unit-tested).
- New per-space 'Scale (grid cell size)' field (space.cell_cm, default 5 cm) so each
  plan has its own real-world dimensions. i18n en/ru, .measurelabel style. +3 tests (61).
This commit is contained in:
Matysh
2026-07-14 18:48:56 +03:00
parent 6cbf4ece4e
commit 18a0d279a7
13 changed files with 163 additions and 14 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.17.2" VERSION = "1.18.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.17.2" "version": "1.18.0"
} }
+26 -4
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## v1.18.0 — 2026-07-14 (live measurements while drawing rooms + per-space scale)
- **Ruler while drawing.** In room-markup "draw" mode, a badge follows the cursor showing the
length of the current segment (last placed vertex → cursor). Units come from the HA unit
system: metric → metres ("1.25 m"), imperial → feet+inches ("4 1″").
- **Per-space scale.** New "Scale (grid cell size)" field in the space dialog — cm represented
by one grid cell (default **5 cm**, i.e. 240 cells ≈ 12 m). Stored as `space.cell_cm`; each
plan can have its own real-world size.
- Pure helpers `segmentCm` / `formatLength` in logic.ts (unit-tested); `_fmtLen` +
`_renderMeasureLabel` in the card; `.measurelabel` style; i18n `space.scale_label`/`scale_unit`.
(+3 tests: 58 → 61 frontend.)
## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon) ## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon)
- Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary - Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary
entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon. entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon.
+2 -2
View File
@@ -13,14 +13,14 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.17.2** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.18.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 | | GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) | | CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion | | HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations | | Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) | | Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
| Tests | 58 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) | | Tests | 61 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
## Recent milestones (details in CHANGELOG.md) ## Recent milestones (details in CHANGELOG.md)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.17.2", "version": "1.18.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+41 -1
View File
@@ -13,6 +13,7 @@ import {
} from './rules'; } from './rules';
import { import {
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
@@ -26,7 +27,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.17.2'; const CARD_VERSION = '1.18.0';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -132,6 +133,7 @@ class HouseplanCard extends LitElement {
fillMode: 'none' | 'lqi' | 'light' | 'temp'; fillMode: 'none' | 'lqi' | 'light' | 'temp';
tempMin: number; tempMin: number;
tempMax: number; tempMax: number;
cellCm: number; // real-world cm represented by one grid cell
busy: boolean; busy: boolean;
} | null = null; } | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e); private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
@@ -944,6 +946,18 @@ class HouseplanCard extends LitElement {
return NORM_W / GRID_N; return NORM_W / GRID_N;
} }
/** cm represented by one grid cell for the current space (default 5). */
private get _cellCm(): number {
const v = Number(this._curSpaceCfg?.cell_cm);
return Number.isFinite(v) && v > 0 ? v : 5;
}
/** Human-readable length of a segment (render units) using the HA unit system. */
private _fmtLen(a: number[], b: number[]): string {
const cm = segmentCm(a, b, this._gridPitch, this._cellCm);
return formatLength(cm, this.hass?.config?.unit_system?.length === 'mi');
}
private get _curSpaceCfg(): any { private get _curSpaceCfg(): any {
return this._serverCfg?.spaces.find((s: any) => s.id === this._space); return this._serverCfg?.spaces.find((s: any) => s.id === this._space);
} }
@@ -1509,6 +1523,7 @@ class HouseplanCard extends LitElement {
showBorders: disp.showBorders, showNames: disp.showNames, showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill, roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax, tempMin: disp.tempMin, tempMax: disp.tempMax,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
busy: false, busy: false,
}; };
} else { } else {
@@ -1518,6 +1533,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false, showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
cellCm: 5,
busy: false, busy: false,
}; };
} }
@@ -1602,6 +1618,7 @@ class HouseplanCard extends LitElement {
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN, temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX, temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
}; };
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
await this._saveConfigNow(); await this._saveConfigNow();
this._spaceDialog = null; this._spaceDialog = null;
if (d.mode === 'create') this._space = sp.id; if (d.mode === 'create') this._space = sp.id;
@@ -1690,6 +1707,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false, showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
cellCm: 5,
busy: false, busy: false,
}; };
} }
@@ -2007,6 +2025,9 @@ class HouseplanCard extends LitElement {
${disp.showNames && !this._markup ${disp.showNames && !this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp)) ? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing} : nothing}
${this._markup && this._tool === 'draw' && this._path.length && this._cursorPt && !this._contourClosed
? this._renderMeasureLabel(view)
: nothing}
</div> </div>
</div> </div>
${this._zoom > 1 ${this._zoom > 1
@@ -2128,6 +2149,15 @@ class HouseplanCard extends LitElement {
>${r.name}</div>`; >${r.name}</div>`;
} }
/** Length badge that follows the cursor while drawing the current segment. */
private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const a = this._path[this._path.length - 1];
const b = this._cursorPt!;
const left = ((b[0] - view.x) / view.w) * 100;
const top = ((b[1] - view.y) / view.h) * 100;
return html`<div class="measurelabel" style="left:${left}%;top:${top}%">${this._fmtLen(a, b)}</div>`;
}
private _roomCenter(r: RoomCfg): number[] { private _roomCenter(r: RoomCfg): number[] {
if (r.poly) { if (r.poly) {
const n = r.poly.length; const n = r.poly.length;
@@ -2402,6 +2432,16 @@ class HouseplanCard extends LitElement {
</select>` </select>`
: nothing} : nothing}
<label>${this._t('space.scale_label')}</label>
<div class="colorrow">
<input class="namein tempin" type="number" min="0.1" step="0.1" .value=${String(d.cellCm)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
this._spaceDialog = { ...d, cellCm: Number.isFinite(n) && n > 0 ? n : d.cellCm };
}} />
<span class="opl">${this._t('space.scale_unit')}</span>
</div>
<label class="dispsection">${this._t('space.display_section')}</label> <label class="dispsection">${this._t('space.display_section')}</label>
<label class="srcrow"> <label class="srcrow">
<input type="checkbox" .checked=${d.showBorders} <input type="checkbox" .checked=${d.showBorders}
+2
View File
@@ -152,6 +152,8 @@
"import.progress": "Floor {i} of {n}", "import.progress": "Floor {i} of {n}",
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.", "import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
"btn.skip": "Skip", "btn.skip": "Skip",
"space.scale_label": "Scale (grid cell size)",
"space.scale_unit": "cm per cell",
"space.display_section": "Display", "space.display_section": "Display",
"space.show_borders": "Always show room borders", "space.show_borders": "Always show room borders",
"space.show_names": "Show room names (drag to move)", "space.show_names": "Show room names (drag to move)",
+2
View File
@@ -152,6 +152,8 @@
"import.progress": "Этаж {i} из {n}", "import.progress": "Этаж {i} из {n}",
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.", "import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"btn.skip": "Пропустить", "btn.skip": "Пропустить",
"space.scale_label": "Масштаб (размер клетки сетки)",
"space.scale_unit": "см на клетку",
"space.display_section": "Отображение", "space.display_section": "Отображение",
"space.show_borders": "Всегда отображать границы комнат", "space.show_borders": "Всегда отображать границы комнат",
"space.show_names": "Отображать названия комнат (перетаскиваются)", "space.show_names": "Отображать названия комнат (перетаскиваются)",
+18
View File
@@ -13,6 +13,24 @@ export function snapToGrid(v: number, pitch: number): number {
return Math.round(v / pitch) * pitch; return Math.round(v / pitch) * 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). */ /** Canonical key of a segment (independent of direction). */
export function segKey(a: number[], b: number[]): string { export function segKey(a: number[], b: number[]): string {
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a]; const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
+14
View File
@@ -236,6 +236,20 @@ export const cardStyles = css`
z-index: 1; z-index: 1;
} }
.roomlabel:active { cursor: grabbing; } .roomlabel:active { cursor: grabbing; }
.measurelabel {
position: absolute;
transform: translate(12px, -150%);
font-size: 12px;
font-weight: 600;
padding: 1px 6px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
white-space: nowrap;
pointer-events: none;
user-select: none;
z-index: 3;
}
.rlabel { .rlabel {
fill: var(--hp-muted); fill: var(--hp-muted);
font-size: 15px; font-size: 15px;
+18
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor, fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
segmentCm, formatLength,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses, iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -258,3 +259,20 @@ test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () =>
assert.equal(o.tempMin, 18.5); assert.equal(o.tempMin, 18.5);
assert.equal(o.tempMax, 23); assert.equal(o.tempMax, 23);
}); });
test('segmentCm: cells scaled by cm-per-cell', () => {
assert.equal(segmentCm([0, 0], [30, 40], 10, 5), 25); // 50 units / pitch 10 = 5 cells * 5cm
assert.ok(Math.abs(segmentCm([0, 0], [240, 0], 1000 / 240, 5) - 288) < 1e-9);
});
test('formatLength: metric metres with 2 decimals', () => {
assert.equal(formatLength(25, false), '0.25 m');
assert.equal(formatLength(125, false), '1.25 m');
assert.equal(formatLength(0, false), '0.00 m');
});
test('formatLength: imperial feet + inches, with inch rollover', () => {
assert.equal(formatLength(124.46, true), '4 1″');
assert.equal(formatLength(30.48, true), '1 0″');
assert.equal(formatLength(29.464, true), '1 0″');
});