mirror of
https://github.com/Matysh/houseplan-card
synced 2026-08-01 00:48:29 +00:00
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:
+7
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user