feat v1.8.2: векторно-чёткий зум через viewBox (без растрового мыла) + сцена на всю ширину вьюпорта без чёрных полей

This commit is contained in:
JB
2026-07-05 10:57:36 +03:00
parent 677e417771
commit 5f6eee64d9
7 changed files with 252 additions and 184 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко
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.8.1" VERSION = "1.8.2"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues", "issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
"requirements": [], "requirements": [],
"version": "1.8.1" "version": "1.8.2"
} }
+50 -52
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -1,5 +1,19 @@
# Changelog # 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 + сохранение зума по пространствам) ## v1.8.1 — 2026-07-04 (fit-to-viewport + сохранение зума по пространствам)
- Уменьшение масштаба до полного вида: нижний предел зума = 100% совпадает с полностью - Уменьшение масштаба до полного вида: нижний предел зума = 100% совпадает с полностью
видимым планом. `.stage` теперь ограничена по высоте вьюпорта видимым планом. `.stage` теперь ограничена по высоте вьюпорта
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.8.1", "version": "1.8.2",
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)", "description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+135 -77
View File
@@ -13,7 +13,7 @@ import {
} from './logic'; } from './logic';
import './editor'; import './editor';
const CARD_VERSION = '1.8.1'; const CARD_VERSION = '1.8.2';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
@@ -147,12 +147,13 @@ class HouseplanCard extends LitElement {
private _roomDialog = false; private _roomDialog = false;
// зум/панорама плана (зум сохраняется по пространству локально) // зум/панорама плана (зум сохраняется по пространству локально)
private _zoom = 1; 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<string, number> = {}; private _zoomBySpace: Record<string, number> = {};
private _pointers = new Map<number, { x: number; y: number }>(); private _pointers = new Map<number, { x: number; y: number }>();
private _panStart: { sx: number; sy: number; ox: number; oy: number } | null = null; private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
private _pinchStart: { dist: number; zoom: number; cx: number; cy: number; px: number; py: number } | null = null; private _pinchStart: { dist: number; zoom: number } | null = null;
private _suppressClick = false; private _suppressClick = false;
private _roViewport?: ResizeObserver;
private _infoCard: DevItem | null = null; private _infoCard: DevItem | null = null;
private _markerDialog: { private _markerDialog: {
@@ -202,7 +203,7 @@ class HouseplanCard extends LitElement {
_infoCard: { state: true }, _infoCard: { state: true },
_markerDialog: { state: true }, _markerDialog: { state: true },
_zoom: { state: true }, _zoom: { state: true },
_pan: { state: true }, _view: { state: true },
}; };
public connectedCallback(): void { public connectedCallback(): void {
@@ -212,6 +213,8 @@ class HouseplanCard extends LitElement {
public disconnectedCallback(): void { public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler); window.removeEventListener('keydown', this._keyHandler);
this._roViewport?.disconnect();
this._roViewport = undefined;
if (this._unsubCfg) { if (this._unsubCfg) {
this._unsubCfg(); this._unsubCfg();
this._unsubCfg = null; 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<void> { private async _loadFromServer(): Promise<void> {
@@ -842,29 +854,83 @@ class HouseplanCard extends LitElement {
return this.renderRoot.querySelector('.stage') as HTMLElement | null; return this.renderRoot.querySelector('.stage') as HTMLElement | null;
} }
/** Ограничить пан так, чтобы контент покрывал сцену (без «дыр» по краям). */ /** Соотношение сторон сцены (ширина/высота, px). */
private _clampPan(): void { private _stageAspect(): number {
const stage = this._stageEl; const s = this._stageEl;
if (!stage) return; const vb = this._spaceModel().vb;
const w = stage.clientWidth; return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
const h = stage.clientHeight; }
const minX = w * (1 - this._zoom);
const minY = h * (1 - this._zoom); /** Прямоугольник «contain» с аспектом сцены, вмещающий весь план (vb). */
this._pan = { private _fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } {
x: Math.min(0, Math.max(minX, this._pan.x)), const planA = vb[2] / vb[3];
y: Math.min(0, Math.max(minY, this._pan.y)), 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) относительно сцены. */ /** Установить зум (центр точка vb cx,cy либо центр текущего view). */
private _zoomAt(sx: number, sy: number, newZoom: number): void { private _applyView(zoom: number, cx?: number, cy?: number): void {
const z = Math.min(8, Math.max(1, newZoom)); const vb = this._spaceModel().vb;
// точка под курсором остаётся на месте: pan' = s - (s - pan)/zoom * z const fit = this._fitView(vb, this._stageAspect());
const lx = (sx - this._pan.x) / this._zoom; const z = Math.min(8, Math.max(1, zoom));
const ly = (sy - this._pan.y) / this._zoom; const w = fit.w / z, h = fit.h / z;
this._pan = { x: sx - lx * z, y: sy - ly * 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._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 { private _onWheel(ev: WheelEvent): void {
@@ -885,8 +951,9 @@ class HouseplanCard extends LitElement {
} }
private _resetZoom(): void { private _resetZoom(): void {
const vb = this._spaceModel().vb;
this._zoom = 1; this._zoom = 1;
this._pan = { x: 0, y: 0 }; this._view = this._fitView(vb, this._stageAspect());
this._saveZoom(); this._saveZoom();
} }
@@ -900,21 +967,16 @@ class HouseplanCard extends LitElement {
} }
} }
/** Восстановить сохранённый зум пространства и центрировать. */ /** Восстановить сохранённый зум пространства и центрировать план. */
private _restoreZoom(): void { private _restoreZoom(): void {
this._zoom = this._zoomBySpace[this._space] || 1; const z = this._zoomBySpace[this._space] || 1;
this._pan = { x: 0, y: 0 }; this._zoom = z;
// центрировать после применения размеров this._view = null;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const stage = this._stageEl; if (!this._stageEl) return;
if (stage) { const vb = this._spaceModel().vb;
this._pan = { this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
x: (stage.clientWidth * (1 - this._zoom)) / 2, this.requestUpdate();
y: (stage.clientHeight * (1 - this._zoom)) / 2,
};
this._clampPan();
this.requestUpdate();
}
}); });
} }
@@ -923,21 +985,14 @@ class HouseplanCard extends LitElement {
if (this._drag || this._markup) return; if (this._drag || this._markup) return;
if ((ev.target as HTMLElement).closest('.dev')) return; if ((ev.target as HTMLElement).closest('.dev')) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
const v = this._viewOr(this._spaceModel().vb);
if (this._pointers.size === 1) { 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; this._suppressClick = false;
} else if (this._pointers.size === 2) { } else if (this._pointers.size === 2) {
const pts = [...this._pointers.values()]; const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); 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 };
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._panStart = null; this._panStart = null;
} }
} }
@@ -952,22 +1007,29 @@ class HouseplanCard extends LitElement {
const pts = [...this._pointers.values()]; const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
const scale = dist / (this._pinchStart.dist || 1); const scale = dist / (this._pinchStart.dist || 1);
// зум относительно исходной середины щипка const r = this._stageEl!.getBoundingClientRect();
const z = Math.min(8, Math.max(1, this._pinchStart.zoom * scale)); const cx = (pts[0].x + pts[1].x) / 2 - r.left;
const lx = (this._pinchStart.cx - this._pinchStart.px) / this._pinchStart.zoom; const cy = (pts[0].y + pts[1].y) / 2 - r.top;
const ly = (this._pinchStart.cy - this._pinchStart.py) / this._pinchStart.zoom; this._zoomAt(cx, cy, this._pinchStart.zoom * scale);
this._pan = { x: this._pinchStart.cx - lx * z, y: this._pinchStart.cy - ly * z };
this._zoom = z;
this._clampPan();
this._suppressClick = true; this._suppressClick = true;
this._saveZoom(); this._saveZoom();
} else if (this._panStart) { } else if (this._panStart) {
const ddx = ev.clientX - this._panStart.sx; const ddx = ev.clientX - this._panStart.sx;
const ddy = ev.clientY - this._panStart.sy; const ddy = ev.clientY - this._panStart.sy;
if (Math.abs(ddx) + Math.abs(ddy) > 4) this._suppressClick = true; if (Math.abs(ddx) + Math.abs(ddy) > 4) this._suppressClick = true;
if (this._zoom > 1) { if (this._zoom > 1 && this._view) {
this._pan = { x: this._panStart.ox + ddx, y: this._panStart.oy + ddy }; const stage = this._stageEl!;
this._clampPan(); 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; if (!stage) return;
const vb = this._spaceModel().vb; const vb = this._spaceModel().vb;
const rect = stage.getBoundingClientRect(); const rect = stage.getBoundingClientRect();
const dx = ((ev.clientX - this._drag.sx) / (rect.width * this._zoom)) * vb[2]; const v = this._viewOr(vb);
const dy = ((ev.clientY - this._drag.sy) / (rect.height * this._zoom)) * vb[3]; 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; 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 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)); 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[] { private _svgPoint(ev: MouseEvent): number[] {
const stage = this.renderRoot.querySelector('.stage') as HTMLElement; const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
const vb = this._spaceModel().vb;
const r = stage.getBoundingClientRect(); const r = stage.getBoundingClientRect();
// экран → локальные координаты (отмена transform: translate(pan) scale(zoom)) return this._screenToVb(ev.clientX - r.left, ev.clientY - r.top);
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]];
} }
private _snap(p: number[]): number[] { private _snap(p: number[]): number[] {
@@ -1677,6 +1736,7 @@ class HouseplanCard extends LitElement {
const devs = this._devices.filter((d) => d.space === space.id); const devs = this._devices.filter((d) => d.space === space.id);
const cfgSize = this._config.icon_size ?? 2.5; const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize; const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const view = this._viewOr(vb);
return html` return html`
<ha-card> <ha-card>
@@ -1744,15 +1804,15 @@ class HouseplanCard extends LitElement {
</div> </div>
<div class="stage ${this._edit ? 'edit' : ''} ${this._markup ? 'markup' : ''}" <div class="stage ${this._edit ? 'edit' : ''} ${this._markup ? 'markup' : ''}"
style="aspect-ratio:${vb[2]}/${vb[3]};width:min(100%,calc((100dvh - 118px) * ${(vb[2] / vb[3]).toFixed(4)}))" style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)} @click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)} @pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)}
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)} @pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)} @pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}> @pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
<div class="zoomwrap" style="transform:translate(${this._pan.x}px,${this._pan.y}px) scale(${this._zoom});"> <div class="zoomwrap">
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet"> <svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${this._markup ? this._renderMarkupDefs(vb) : nothing} ${this._markup ? this._renderMarkupDefs(vb) : nothing}
${space.bg ${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />` ? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
@@ -1776,8 +1836,8 @@ class HouseplanCard extends LitElement {
})} })}
${this._markup ? this._renderMarkupLayer(vb) : nothing} ${this._markup ? this._renderMarkupLayer(vb) : nothing}
</svg> </svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw"> <div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, vb))} ${devs.map((d) => this._renderDevice(d, view))}
</div> </div>
</div> </div>
${this._zoom > 1 ${this._zoom > 1
@@ -1803,10 +1863,10 @@ class HouseplanCard extends LitElement {
`; `;
} }
private _renderDevice(d: DevItem, vb: number[]): TemplateResult { private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }): TemplateResult {
const p = this._pos(d); const p = this._pos(d);
const left = ((p.x - vb[0]) / vb[2]) * 100; const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100; const top = ((p.y - view.y) / view.h) * 100;
const cls = this._stateClass(d); const cls = this._stateClass(d);
const temp = this._liveTemp(d); const temp = this._liveTemp(d);
const lqi = this._config?.show_signal && !d.virtual ? this._lqiFor(d.entities) : null; const lqi = this._config?.show_signal && !d.virtual ? this._lqiFor(d.entities) : null;
@@ -2272,17 +2332,15 @@ class HouseplanCard extends LitElement {
} }
.stage { .stage {
position: relative; position: relative;
max-width: 100%; width: 100%;
margin: 0 auto; /* центрировать, когда ширина ограничена высотой экрана */
container-type: inline-size; container-type: inline-size;
overflow: hidden; overflow: hidden;
touch-action: none; /* свои жесты pinch/pan */ touch-action: none; /* свои жесты pinch/pan */
background: var(--ha-card-background, var(--card-background-color, #111));
} }
.zoomwrap { .zoomwrap {
position: absolute; position: absolute;
inset: 0; inset: 0;
transform-origin: 0 0;
will-change: transform;
} }
.zoomctl { .zoomctl {
display: inline-flex; display: inline-flex;