`}}wt.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_edit:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_pan:{state:!0}},wt.styles=((t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new a(s,t,i)})`
+
`}}wt.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_edit:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},wt.styles=((t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new a(s,t,i)})`
:host {
--hp-bg: var(--card-background-color, #16212e);
--hp-line: var(--divider-color, #2b3d4f);
@@ -447,17 +447,15 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
}
.stage {
position: relative;
- max-width: 100%;
- margin: 0 auto; /* центрировать, когда ширина ограничена высотой экрана */
+ width: 100%;
container-type: inline-size;
overflow: hidden;
touch-action: none; /* свои жесты pinch/pan */
+ background: var(--ha-card-background, var(--card-background-color, #111));
}
.zoomwrap {
position: absolute;
inset: 0;
- transform-origin: 0 0;
- will-change: transform;
}
.zoomctl {
display: inline-flex;
@@ -1036,4 +1034,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
z-index: 120;
max-width: 90vw;
}
- `,customElements.get("houseplan-card")||customElements.define("houseplan-card",wt),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.8.1 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+ `,customElements.get("houseplan-card")||customElements.define("houseplan-card",wt),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.8.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 81fea7c..8b9fc56 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## v1.8.2 — 2026-07-04 (векторно-чёткий зум через viewBox + полная ширина сцены)
+- Зум переведён с CSS `transform: scale()` на манипуляцию `viewBox` SVG. Раньше слой
+ растрировался и потом масштабировался — при приближении всё «мылилось». Теперь браузер
+ перерисовывает вектор в целевом разрешении: иконки, подписи комнат, линии, штриховка и
+ векторная подложка остаются чёткими на любом масштабе (проверено на 274%).
+- Слой иконок (HTML) позиционируется и масштабируется по тому же `view`, а не общим
+ transform — иконки чёткие и растут вместе с планом.
+- Сцена теперь занимает всю ширину вьюпорта (модель «contain» по аспекту сцены): при
+ зум-ине контент заполняет ширину без чёрных полей по бокам. При полном отдалении виден
+ весь план (по ширине и высоте), поля вокруг залиты фоном карточки, а не чёрным.
+- Вся координатная математика (клик разметки `_svgPoint`, drag иконок, pan, pinch) переписана
+ в единых `view`-координатах — редактирование корректно на любом зуме. ResizeObserver сцены
+ пересчитывает `view` при изменении размеров (сайдбар, поворот). Пан/зум ограничены `fit`.
+
## v1.8.1 — 2026-07-04 (fit-to-viewport + сохранение зума по пространствам)
- Уменьшение масштаба до полного вида: нижний предел зума = 100% совпадает с полностью
видимым планом. `.stage` теперь ограничена по высоте вьюпорта
diff --git a/package.json b/package.json
index 0744a57..1cdc900 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
- "version": "1.8.1",
+ "version": "1.8.2",
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
"license": "MIT",
"type": "module",
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 457dd6f..2e8a84c 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -13,7 +13,7 @@ import {
} from './logic';
import './editor';
-const CARD_VERSION = '1.8.1';
+const CARD_VERSION = '1.8.2';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_ZOOM = 'houseplan_card_zoom_v1';
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
@@ -147,12 +147,13 @@ class HouseplanCard extends LitElement {
private _roomDialog = false;
// зум/панорама плана (зум сохраняется по пространству локально)
private _zoom = 1;
- private _pan = { x: 0, y: 0 };
+ private _view: { x: number; y: number; w: number; h: number } | null = null; // текущий viewBox SVG (vb-координаты)
private _zoomBySpace: Record = {};
private _pointers = new Map();
- private _panStart: { sx: number; sy: number; ox: number; oy: number } | null = null;
- private _pinchStart: { dist: number; zoom: number; cx: number; cy: number; px: number; py: number } | null = null;
+ private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
+ private _pinchStart: { dist: number; zoom: number } | null = null;
private _suppressClick = false;
+ private _roViewport?: ResizeObserver;
private _infoCard: DevItem | null = null;
private _markerDialog: {
@@ -202,7 +203,7 @@ class HouseplanCard extends LitElement {
_infoCard: { state: true },
_markerDialog: { state: true },
_zoom: { state: true },
- _pan: { state: true },
+ _view: { state: true },
};
public connectedCallback(): void {
@@ -212,6 +213,8 @@ class HouseplanCard extends LitElement {
public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler);
+ this._roViewport?.disconnect();
+ this._roViewport = undefined;
if (this._unsubCfg) {
this._unsubCfg();
this._unsubCfg = null;
@@ -347,6 +350,15 @@ class HouseplanCard extends LitElement {
}
}
+ protected updated(): void {
+ const stage = this._stageEl;
+ if (stage && !this._roViewport) {
+ this._roViewport = new ResizeObserver(() => this._refitView());
+ this._roViewport.observe(stage);
+ }
+ if (stage && !this._view) this._refitView();
+ }
+
// ================= сервер: конфиг + раскладка =================
private async _loadFromServer(): Promise {
@@ -842,29 +854,83 @@ class HouseplanCard extends LitElement {
return this.renderRoot.querySelector('.stage') as HTMLElement | null;
}
- /** Ограничить пан так, чтобы контент покрывал сцену (без «дыр» по краям). */
- private _clampPan(): void {
- const stage = this._stageEl;
- if (!stage) return;
- const w = stage.clientWidth;
- const h = stage.clientHeight;
- const minX = w * (1 - this._zoom);
- const minY = h * (1 - this._zoom);
- this._pan = {
- x: Math.min(0, Math.max(minX, this._pan.x)),
- y: Math.min(0, Math.max(minY, this._pan.y)),
+ /** Соотношение сторон сцены (ширина/высота, px). */
+ private _stageAspect(): number {
+ const s = this._stageEl;
+ const vb = this._spaceModel().vb;
+ return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
+ }
+
+ /** Прямоугольник «contain» с аспектом сцены, вмещающий весь план (vb). */
+ private _fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } {
+ const planA = vb[2] / vb[3];
+ if (aspect > planA) {
+ const h = vb[3], w = vb[3] * aspect;
+ return { x: vb[0] - (w - vb[2]) / 2, y: vb[1], w, h };
+ }
+ const w = vb[2], h = vb[2] / aspect;
+ return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h };
+ }
+
+ /** Текущий view с фолбэком на полный fit. */
+ private _viewOr(vb: number[]): { x: number; y: number; w: number; h: number } {
+ return this._view && this._view.w ? this._view : this._fitView(vb, this._stageAspect());
+ }
+
+ /** Экран (sx,sy относительно сцены, px) → координаты vb по текущему view. */
+ private _screenToVb(sx: number, sy: number): number[] {
+ const s = this._stageEl;
+ const v = this._viewOr(this._spaceModel().vb);
+ const w = s?.clientWidth || 1, h = s?.clientHeight || 1;
+ return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
+ }
+
+ /** Ограничить view пределами fit (контент всегда покрывает сцену). */
+ private _clampView(
+ v: { x: number; y: number; w: number; h: number },
+ fit: { x: number; y: number; w: number; h: number },
+ ): { x: number; y: number; w: number; h: number } {
+ return {
+ w: v.w,
+ h: v.h,
+ x: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)),
+ y: Math.max(fit.y, Math.min(fit.y + fit.h - v.h, v.y)),
};
}
- /** Изменить зум с центром в точке (sx,sy) относительно сцены. */
- private _zoomAt(sx: number, sy: number, newZoom: number): void {
- const z = Math.min(8, Math.max(1, newZoom));
- // точка под курсором остаётся на месте: pan' = s - (s - pan)/zoom * z
- const lx = (sx - this._pan.x) / this._zoom;
- const ly = (sy - this._pan.y) / this._zoom;
- this._pan = { x: sx - lx * z, y: sy - ly * z };
+ /** Установить зум (центр — точка vb cx,cy либо центр текущего view). */
+ private _applyView(zoom: number, cx?: number, cy?: number): void {
+ const vb = this._spaceModel().vb;
+ const fit = this._fitView(vb, this._stageAspect());
+ const z = Math.min(8, Math.max(1, zoom));
+ const w = fit.w / z, h = fit.h / z;
+ const cur = this._viewOr(vb);
+ const ccx = cx ?? cur.x + cur.w / 2;
+ const ccy = cy ?? cur.y + cur.h / 2;
this._zoom = z;
- this._clampPan();
+ this._view = this._clampView({ x: ccx - w / 2, y: ccy - h / 2, w, h }, fit);
+ }
+
+ /** Пересчитать view под новый размер сцены, сохранив зум и центр. */
+ private _refitView(): void {
+ if (!this._stageEl) return;
+ const cur = this._view;
+ this._applyView(this._zoom, cur ? cur.x + cur.w / 2 : undefined, cur ? cur.y + cur.h / 2 : undefined);
+ this.requestUpdate();
+ }
+
+ /** Изменить зум, удерживая точку (sx,sy относительно сцены) на месте. */
+ private _zoomAt(sx: number, sy: number, newZoom: number): void {
+ const stage = this._stageEl;
+ if (!stage) return;
+ const vb = this._spaceModel().vb;
+ const fit = this._fitView(vb, this._stageAspect());
+ const z = Math.min(8, Math.max(1, newZoom));
+ const w = stage.clientWidth, h = stage.clientHeight;
+ const pt = this._screenToVb(sx, sy);
+ const nw = fit.w / z, nh = fit.h / z;
+ this._zoom = z;
+ this._view = this._clampView({ x: pt[0] - (sx / w) * nw, y: pt[1] - (sy / h) * nh, w: nw, h: nh }, fit);
}
private _onWheel(ev: WheelEvent): void {
@@ -885,8 +951,9 @@ class HouseplanCard extends LitElement {
}
private _resetZoom(): void {
+ const vb = this._spaceModel().vb;
this._zoom = 1;
- this._pan = { x: 0, y: 0 };
+ this._view = this._fitView(vb, this._stageAspect());
this._saveZoom();
}
@@ -900,21 +967,16 @@ class HouseplanCard extends LitElement {
}
}
- /** Восстановить сохранённый зум пространства и центрировать. */
+ /** Восстановить сохранённый зум пространства и центрировать план. */
private _restoreZoom(): void {
- this._zoom = this._zoomBySpace[this._space] || 1;
- this._pan = { x: 0, y: 0 };
- // центрировать после применения размеров
+ const z = this._zoomBySpace[this._space] || 1;
+ this._zoom = z;
+ this._view = null;
requestAnimationFrame(() => {
- const stage = this._stageEl;
- if (stage) {
- this._pan = {
- x: (stage.clientWidth * (1 - this._zoom)) / 2,
- y: (stage.clientHeight * (1 - this._zoom)) / 2,
- };
- this._clampPan();
- this.requestUpdate();
- }
+ if (!this._stageEl) return;
+ const vb = this._spaceModel().vb;
+ this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
+ this.requestUpdate();
});
}
@@ -923,21 +985,14 @@ class HouseplanCard extends LitElement {
if (this._drag || this._markup) return;
if ((ev.target as HTMLElement).closest('.dev')) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
+ const v = this._viewOr(this._spaceModel().vb);
if (this._pointers.size === 1) {
- this._panStart = { sx: ev.clientX, sy: ev.clientY, ox: this._pan.x, oy: this._pan.y };
+ this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
this._suppressClick = false;
} else if (this._pointers.size === 2) {
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
- const r = this._stageEl!.getBoundingClientRect();
- this._pinchStart = {
- dist,
- zoom: this._zoom,
- cx: (pts[0].x + pts[1].x) / 2 - r.left,
- cy: (pts[0].y + pts[1].y) / 2 - r.top,
- px: this._pan.x,
- py: this._pan.y,
- };
+ this._pinchStart = { dist, zoom: this._zoom };
this._panStart = null;
}
}
@@ -952,22 +1007,29 @@ class HouseplanCard extends LitElement {
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
const scale = dist / (this._pinchStart.dist || 1);
- // зум относительно исходной середины щипка
- const z = Math.min(8, Math.max(1, this._pinchStart.zoom * scale));
- const lx = (this._pinchStart.cx - this._pinchStart.px) / this._pinchStart.zoom;
- const ly = (this._pinchStart.cy - this._pinchStart.py) / this._pinchStart.zoom;
- this._pan = { x: this._pinchStart.cx - lx * z, y: this._pinchStart.cy - ly * z };
- this._zoom = z;
- this._clampPan();
+ const r = this._stageEl!.getBoundingClientRect();
+ const cx = (pts[0].x + pts[1].x) / 2 - r.left;
+ const cy = (pts[0].y + pts[1].y) / 2 - r.top;
+ this._zoomAt(cx, cy, this._pinchStart.zoom * scale);
this._suppressClick = true;
this._saveZoom();
} else if (this._panStart) {
const ddx = ev.clientX - this._panStart.sx;
const ddy = ev.clientY - this._panStart.sy;
if (Math.abs(ddx) + Math.abs(ddy) > 4) this._suppressClick = true;
- if (this._zoom > 1) {
- this._pan = { x: this._panStart.ox + ddx, y: this._panStart.oy + ddy };
- this._clampPan();
+ if (this._zoom > 1 && this._view) {
+ const stage = this._stageEl!;
+ const v = this._view;
+ const fit = this._fitView(this._spaceModel().vb, this._stageAspect());
+ this._view = this._clampView(
+ {
+ x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
+ y: this._panStart.vy - (ddy / stage.clientHeight) * v.h,
+ w: v.w,
+ h: v.h,
+ },
+ fit,
+ );
}
}
}
@@ -1002,8 +1064,9 @@ class HouseplanCard extends LitElement {
if (!stage) return;
const vb = this._spaceModel().vb;
const rect = stage.getBoundingClientRect();
- const dx = ((ev.clientX - this._drag.sx) / (rect.width * this._zoom)) * vb[2];
- const dy = ((ev.clientY - this._drag.sy) / (rect.height * this._zoom)) * vb[3];
+ const v = this._viewOr(vb);
+ const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
+ const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true;
const m = Math.min(vb[2], vb[3]) * 0.008;
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
@@ -1087,12 +1150,8 @@ class HouseplanCard extends LitElement {
private _svgPoint(ev: MouseEvent): number[] {
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
- const vb = this._spaceModel().vb;
const r = stage.getBoundingClientRect();
- // экран → локальные координаты (отмена transform: translate(pan) scale(zoom))
- const lx = (ev.clientX - r.left - this._pan.x) / this._zoom;
- const ly = (ev.clientY - r.top - this._pan.y) / this._zoom;
- return [vb[0] + (lx / r.width) * vb[2], vb[1] + (ly / r.height) * vb[3]];
+ return this._screenToVb(ev.clientX - r.left, ev.clientY - r.top);
}
private _snap(p: number[]): number[] {
@@ -1677,6 +1736,7 @@ class HouseplanCard extends LitElement {
const devs = this._devices.filter((d) => d.space === space.id);
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
+ const view = this._viewOr(vb);
return html`
@@ -1744,15 +1804,15 @@ class HouseplanCard extends LitElement {