mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
feat v1.8.2: векторно-чёткий зум через viewBox (без растрового мыла) + сцена на всю ширину вьюпорта без чёрных полей
This commit is contained in:
@@ -10,7 +10,7 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.8.1"
|
||||
VERSION = "1.8.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"version": "1.8.1"
|
||||
"version": "1.8.2"
|
||||
}
|
||||
Vendored
+50
-52
File diff suppressed because one or more lines are too long
@@ -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` теперь ограничена по высоте вьюпорта
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+135
-77
@@ -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<string, number> = {};
|
||||
private _pointers = new Map<number, { x: number; y: number }>();
|
||||
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<void> {
|
||||
@@ -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`
|
||||
<ha-card>
|
||||
@@ -1744,15 +1804,15 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
@pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)}
|
||||
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
|
||||
@pointerup=${(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});">
|
||||
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
|
||||
<div class="zoomwrap">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
${this._markup ? this._renderMarkupDefs(vb) : nothing}
|
||||
${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" />`
|
||||
@@ -1776,8 +1836,8 @@ class HouseplanCard extends LitElement {
|
||||
})}
|
||||
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||
</svg>
|
||||
<div class="devlayer" style="--icon-size:${iconPct}cqw">
|
||||
${devs.map((d) => this._renderDevice(d, vb))}
|
||||
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
|
||||
${devs.map((d) => this._renderDevice(d, view))}
|
||||
</div>
|
||||
</div>
|
||||
${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 left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||
const left = ((p.x - view.x) / view.w) * 100;
|
||||
const top = ((p.y - view.y) / view.h) * 100;
|
||||
const cls = this._stateClass(d);
|
||||
const temp = this._liveTemp(d);
|
||||
const lqi = this._config?.show_signal && !d.virtual ? this._lqiFor(d.entities) : null;
|
||||
@@ -2272,17 +2332,15 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user