v1.49.0: content-fit zoom, swipe animation, wording, and the v1.47.0 review

Owner's batch:
- zoom now opens on what is DRAWN (rooms + 5% margin) for spaces with no
  background image; with one the image is the plan and still fits whole. A small
  plan on the square canvas no longer opens as a speck.
- swiping between spaces, and the kiosk carousel, slide sideways; honours
  prefers-reduced-motion.
- the room settings button reads 'Room settings' and lightens on hover.
- 'curation' is filtering everywhere: UI strings, docs, code.

Checked the yard while I was there: its drawing sits off-centre because it was
drawn that way — before the migration x spanned 0.12..0.54 with 0.12 and 0.46 of
margin. The migration added 0.1465 on each side, symmetrically. Content-fit zoom
makes it moot anyway.

From the v1.47.0 review:
- HP-1470-02: the picker let you delete the plan you had just selected — it is
  not in the stored config yet, so the server rightly called it free, and the
  save then stored a url with no file. The button is disabled, and since two
  clients can do this in either order, config/set now verifies every internal
  plan url against the disk under the write lock and answers .
  External and legacy urls are not ours to police.
- HP-1470-01: growth is bounded at the door rather than by deleting old files —
  that mistake cost real plans twice. check_quota refuses an upload that would
  push the store past 256 MB / 200 plans (1 GB / 1000 attachments) or leave less
  than 512 MB free. The plan list is capped at 60 newest with a total, and
  thumbnails load lazily.
- HP-1470-03: picking a saved plan waited for nothing and stored a fallback
  ratio when the signature had not arrived — a square plan came out stretched.
  It waits for the signature, binds the result to the dialog that asked, and the
  dialog preview is signed too.
- report §5: the last lifecycle comments still described age-based collection.

Not released yet — the owner asked for a release once the batch is done.
This commit is contained in:
Matysh
2026-07-28 22:44:09 +03:00
parent e1e730560d
commit f5e6c0318d
27 changed files with 638 additions and 140 deletions
+7 -7
View File
@@ -1,5 +1,5 @@
/**
* Building the device list from HA registries: curation, light groups,
* Building the device list from HA registries: filtering, light groups,
* markers (overrides/virtual). No Lit/DOM — only the hass object.
*/
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
@@ -184,7 +184,7 @@ function applyMarker(item: DevItem, m: Marker): void {
item.tapAction = m.tap_action ?? null;
}
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
/** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
@@ -210,7 +210,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (marker && marker.hidden) continue;
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
// curation (can be turned off with the “show all” toggle)
// filtering (can be turned off with the “show all” toggle)
if (!showAll) {
if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue;
@@ -391,7 +391,7 @@ export function areaHum(
const vals: number[] = [];
for (const dv of devices) {
if (dv.area !== area) continue;
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
// same filtering idea as areaTemp: climate sensors only, not fridges/plugs
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
const h = humFor(hass, dv.entities);
if (h != null) vals.push(h);
@@ -416,11 +416,11 @@ const NON_AIR_RE = new RegExp(
/**
* Room climate from EVERY sensor of the area — including devices that are not
* placed on the plan (hidden by curation or by the user). The old helpers read
* placed on the plan (hidden by filtering or by the user). The old helpers read
* the visible-icon list, so hiding a thermometer silently removed it from the
* room card (field report, 2026-07-27).
*
* Curation is kept: only devices the card itself recognises as thermometers /
* Filtering is kept: only devices the card itself recognises as thermometers /
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
* The AUTO icon is used on purpose — a custom marker icon must not change what
* a device measures.
@@ -451,7 +451,7 @@ export function areaClimateMap(
// 90 C and a virtual better_thermostat duplicating the real sensor (field
// question, 2026-07-27). Three guards, cheapest first:
if (reg.entity_category) continue; // diagnostic/config readings
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
let groups = byArea.get(area);
if (!groups) { groups = new Map(); byArea.set(area, groups); }
+93 -38
View File
@@ -33,10 +33,10 @@ import type {
import './editor';
import './space-card';
import { cardStyles } from './styles';
import { fitInSquare } from './space-geometry';
import { fitInSquare, contentBounds } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.48.0';
const CARD_VERSION = '1.49.0';
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_ZOOM = 'houseplan_card_zoom_v1';
@@ -164,13 +164,34 @@ class HouseplanCard extends LitElement {
}
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
/**
* Which way the plan should fly out when the space changes. Empty means no
* animation — a direct pick from the tabs should not slide anywhere.
*/
private _slide: '' | 'left' | 'right' = '';
private _slideTimer?: number;
/** Change the space with the usual sideways transition. */
private _slideTo(id: string, dir: 'left' | 'right'): void {
if (id === this._space) return;
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
this._space = id;
this._selId = null;
this._restoreZoom();
if (reduce) return;
this._slide = dir;
clearTimeout(this._slideTimer);
// long enough to be read as motion, short enough not to be in the way
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 260);
this.requestUpdate();
}
private _cycleTick(): void {
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
const ids = this._model.map((m) => m.id);
const i = ids.indexOf(this._space);
this._space = ids[(i + 1) % ids.length];
this._restoreZoom();
this._slideTo(ids[(i + 1) % ids.length], 'left');
this._showKioskDots();
}
}
@@ -404,6 +425,7 @@ class HouseplanCard extends LitElement {
clearTimeout(this._reloadRetry);
this._signer.dispose();
clearTimeout(this._toastTimer);
clearTimeout(this._slideTimer);
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
window.removeEventListener('hashchange', this._onHashChange);
clearTimeout(this._holdTimer);
@@ -1006,7 +1028,7 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
}
/** Curation + light groups + overrides + virtual devices. */
/** Filtering + light groups + overrides + virtual devices. */
private get _markers(): Marker[] {
return this._serverCfg?.markers || [];
}
@@ -1204,10 +1226,23 @@ class HouseplanCard extends LitElement {
return this.renderRoot.querySelector('.stage') as HTMLElement | null;
}
/**
* The rectangle "fit to screen" fits. With a background image that is the
* whole canvas — the image IS the plan. Without one it is what has been
* drawn, plus a small margin, so a single room on a big canvas fills the
* screen instead of sitting in it as a speck.
*/
private _baseVb(): number[] {
const m = this._spaceModel();
if (m.bg) return m.vb;
const b = contentBounds(m);
return b ? [b.x, b.y, b.w, b.h] : m.vb;
}
/** Aspect ratio of the scene (width/height, px). */
private _stageAspect(): number {
const s = this._stageEl;
const vb = this._spaceModel().vb;
const vb = this._baseVb();
return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
}
@@ -1219,7 +1254,7 @@ class HouseplanCard extends LitElement {
/** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */
private _screenToVb(sx: number, sy: number): number[] {
const s = this._stageEl;
const v = this._viewOr(this._spaceModel().vb);
const v = this._viewOr(this._baseVb());
const w = s?.clientWidth || 1, h = s?.clientHeight || 1;
return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
}
@@ -1239,7 +1274,7 @@ class HouseplanCard extends LitElement {
/** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */
private _applyView(zoom: number, cx?: number, cy?: number): void {
const vb = this._spaceModel().vb;
const vb = this._baseVb();
const fit = fitView(vb, this._stageAspect());
const z = Math.min(8, Math.max(1, zoom));
const w = fit.w / z, h = fit.h / z;
@@ -1262,7 +1297,7 @@ class HouseplanCard extends LitElement {
private _zoomAt(sx: number, sy: number, newZoom: number): void {
const stage = this._stageEl;
if (!stage) return;
const vb = this._spaceModel().vb;
const vb = this._baseVb();
const fit = fitView(vb, this._stageAspect());
const z = Math.min(8, Math.max(1, newZoom));
const w = stage.clientWidth, h = stage.clientHeight;
@@ -1290,7 +1325,7 @@ class HouseplanCard extends LitElement {
}
private _resetZoom(): void {
const vb = this._spaceModel().vb;
const vb = this._baseVb();
this._zoom = 1;
this._view = fitView(vb, this._stageAspect());
this._saveZoom();
@@ -1313,7 +1348,7 @@ class HouseplanCard extends LitElement {
this._view = null;
requestAnimationFrame(() => {
if (!this._stageEl) return;
const vb = this._spaceModel().vb;
const vb = this._baseVb();
this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
this.requestUpdate();
});
@@ -1342,7 +1377,7 @@ class HouseplanCard extends LitElement {
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
const v = this._viewOr(this._spaceModel().vb);
const v = this._viewOr(this._baseVb());
if (this._pointers.size === 1) {
this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
this._suppressClick = false;
@@ -1388,7 +1423,7 @@ class HouseplanCard extends LitElement {
if (this._zoom > 1 && this._view) {
const stage = this._stageEl!;
const v = this._view;
const fit = fitView(this._spaceModel().vb, this._stageAspect());
const fit = fitView(this._baseVb(), this._stageAspect());
this._view = this._clampView(
{
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
@@ -1418,9 +1453,9 @@ class HouseplanCard extends LitElement {
}
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
if (target) {
this._space = target;
this._selId = null;
this._restoreZoom();
// the plan follows the finger: swiping left brings the next one in
// from the right, so the current one leaves to the left
this._slideTo(target, dx < 0 ? 'left' : 'right');
this._saveNav();
this._suppressClick = true;
setTimeout(() => (this._suppressClick = false), 0);
@@ -1475,7 +1510,7 @@ class HouseplanCard extends LitElement {
if (!this._drag || this._drag.id !== d.id) return;
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
if (!stage) return;
const vb = this._spaceModel().vb;
const vb = this._baseVb();
const rect = stage.getBoundingClientRect();
const v = this._viewOr(vb);
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
@@ -3094,22 +3129,41 @@ class HouseplanCard extends LitElement {
private _useServerPlan(url: string): void {
const d = this._spaceDialog;
if (!d) return;
// the aspect ratio is read from the image itself, exactly as on upload
const img = new Image();
img.onload = () => {
const cur = this._spaceDialog;
if (!cur) return;
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1.414;
this._spaceDialog = {
...cur, planUrl: url, planFile: null, pickSaved: false,
savedAspect: Number.isFinite(ratio) && ratio > 0 ? ratio : 1.414,
};
};
img.onerror = () => {
const cur = this._spaceDialog;
if (cur) this._spaceDialog = { ...cur, planUrl: url, planFile: null, pickSaved: false, savedAspect: 1.414 };
};
img.src = this._display(url);
// Attach immediately — the click should not wait for anything.
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false };
void this._readPlanAspect(url);
}
/**
* Read a stored plan's proportions from the image itself.
*
* The content endpoint needs a signature, and `_display()` deliberately
* returns nothing until one arrives. Loading too early therefore failed and
* an earlier version treated that as "unknown ratio" and saved a fallback of
* 1.414 — a square plan came out stretched (HP-1470-03). So wait for the
* signature, and bind the result to THIS dialog and THIS url, or a late
* answer would reshape whatever the user opened next.
*/
private async _readPlanAspect(url: string): Promise<void> {
for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly
const src = this._display(url);
if (src) {
const ratio = await new Promise<number>((res) => {
const img = new Image();
img.onload = () => res(img.naturalWidth && img.naturalHeight
? img.naturalWidth / img.naturalHeight : 0);
img.onerror = () => res(0);
img.src = src;
});
const cur = this._spaceDialog;
if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
this._spaceDialog = { ...cur, savedAspect: ratio };
}
return;
}
await new Promise((r) => setTimeout(r, 150));
if (this._spaceDialog?.planUrl !== url) return; // the user moved on
}
}
private async _deleteServerPlan(name: string): Promise<void> {
@@ -3131,7 +3185,7 @@ class HouseplanCard extends LitElement {
return html`<div class="savedplans">
${list.map((p) => html`
<div class="savedplan ${p.url === d.planUrl ? 'cur' : ''}">
<img src=${this._display(p.url)} alt="" />
<img src=${this._display(p.url)} alt="" loading="lazy" decoding="async" />
<div class="savedmeta">
<b>${p.name}</b>
<span class="muted">${kb(p.size)}${p.used_by.length
@@ -3140,8 +3194,9 @@ class HouseplanCard extends LitElement {
</div>
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
?disabled=${p.url === d.planUrl}>${this._t('btn.use')}</button>
<button class="btn ghost danger" title=${p.used_by.length ? this._t('space.in_use') : this._t('btn.delete')}
?disabled=${p.used_by.length > 0}
<button class="btn ghost danger"
title=${p.used_by.length || p.url === d.planUrl ? this._t('space.in_use') : this._t('btn.delete')}
?disabled=${p.used_by.length > 0 || p.url === d.planUrl}
@click=${() => this._deleteServerPlan(p.name)}>
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
</button>
@@ -3830,7 +3885,7 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
<div class="zoomwrap">
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${this._editing ? this._renderMarkupDefs(vb) : nothing}
${this._editing && !this._markup
@@ -5220,7 +5275,7 @@ class HouseplanCard extends LitElement {
${d.planFile
? html`<span class="planname">${d.planFile.name}</span>`
: d.planUrl
? html`<img class="planprev" src=${d.planUrl} alt=${this._t('space.plan_alt')} />`
? html`<img class="planprev" src=${this._display(d.planUrl)} alt=${this._t('space.plan_alt')} />`
: html`<span class="planname muted">${this._t('space.no_plan')}</span>`}
<label class="btn filebtn">
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
+2 -2
View File
@@ -21,7 +21,7 @@
"title.zoom_out": "Zoom out",
"title.zoom_reset": "Reset zoom",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show all area devices (no curation)",
"title.show_all": "Show all area devices (no filtering)",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
@@ -323,7 +323,7 @@
"room.label_scale": "Metrics size",
"preview.room_name": "Living room",
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room",
"room.settings_short": "Room settings",
"room.unnamed": "Unnamed room",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
+2 -2
View File
@@ -21,7 +21,7 @@
"title.zoom_out": "Отдалить",
"title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без курирования)",
"title.show_all": "Показывать все устройства зоны (без фильтрации)",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
@@ -323,7 +323,7 @@
"room.label_scale": "Размер подписей",
"preview.room_name": "Гостиная",
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Комната",
"room.settings_short": "Настройки комнаты",
"room.unnamed": "Комната без имени",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* Device curation and icon rules.
* Device filtering and icon rules.
*
* Icon rules are DATA, not code: the built-in defaults below can be overridden
* per instance via `config.settings.icon_rules` (edited in the card UI).
@@ -8,7 +8,7 @@
* skipped silently at compile time (and flagged in the rules editor).
*/
/** Integration domains whose devices are hidden by default (curation). */
/** Integration domains whose devices are hidden by default (filtering). */
export const EXCLUDED_DOMAINS = new Set([
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
+36 -1
View File
@@ -57,7 +57,42 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
});
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
/**
* What the plan actually occupies, padded by `pad` of the larger side.
*
* The canvas is a square big enough for any house, so a small hand-drawn plan
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
* of the canvas is what people expect but only when there is no background
* image: with one, the image is the plan, and cropping to the rooms would hide
* the parts of it nobody has outlined yet.
*
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
*/
export function contentBounds(
space: SpaceModel, pad = 0.05,
): { x: number; y: number; w: number; h: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const add = (x: number, y: number) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
};
for (const r of space.rooms || []) {
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
else if (r.x != null && r.y != null) {
add(r.x, r.y);
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
if (minX > maxX || minY > maxY) return null;
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
}
/** Bounding rectangle of a room (rect or polygon) in render units. *//** 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]);
+17 -1
View File
@@ -383,6 +383,21 @@ export const cardStyles = css`
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
the way the finger went and the next one arrives from the other side. */
@keyframes hp-slide-left {
0% { transform: translateX(22%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes hp-slide-right {
0% { transform: translateX(-22%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
@media (prefers-reduced-motion: reduce) {
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
}
.rlgearbtn {
display: inline-flex;
align-items: center;
@@ -402,7 +417,8 @@ export const cardStyles = css`
opacity: 0.92;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.rlgearbtn:hover { opacity: 1; }
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;