feat v1.8.0: plan zoom & pan — wheel (to cursor), pinch (touch), background drag pan, toolbar -/reset/+ buttons + scale badge; CSS-transform zoomwrap with zoom/pan-aware coordinate math (markup/drag/tooltips work while zoomed); clamped pan, 100-800%

This commit is contained in:
JB
2026-07-04 20:12:37 +03:00
parent 96d3a182c6
commit 98dbb1534d
7 changed files with 387 additions and 105 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.7.4" VERSION = "1.8.0"
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.7.4" "version": "1.8.0"
} }
+96 -47
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## v1.8.0 — 2026-07-04 (зумирование плана)
- Зум и панорама схемы: колёсико мыши (к курсору), щипок двумя пальцами (pinch) на тач-экране,
перетаскивание фона одним пальцем при увеличении, кнопки −/сброс/+ в тулбаре, бейдж масштаба.
- Реализовано через CSS-transform на `.zoomwrap` (translate+scale); координатная математика
(клик разметки, drag иконок, тултипы) пересчитана с учётом зума/пана — редактирование
работает и при увеличении. Пан ограничен, чтобы контент всегда покрывал сцену. Зум 100–800%.
- `.stage`: overflow hidden + touch-action none (свои жесты вместо браузерных).
## v1.7.4 — 2026-07-04 (PDF по HTTP вместо WebSocket) ## v1.7.4 — 2026-07-04 (PDF по HTTP вместо WebSocket)
- КОРНЕВАЯ ПРИЧИНА невозможности загрузить PDF: файл слался как base64 одним WebSocket-сообщением, - КОРНЕВАЯ ПРИЧИНА невозможности загрузить PDF: файл слался как base64 одним WebSocket-сообщением,
а у WS есть лимит размера — реальный мануал (>2–3 МБ) превышал его, соединение рвалось а у WS есть лимит размера — реальный мануал (>2–3 МБ) превышал его, соединение рвалось
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.7.4", "version": "1.8.0",
"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",
+184 -8
View File
@@ -13,7 +13,7 @@ import {
} from './logic'; } from './logic';
import './editor'; import './editor';
const CARD_VERSION = '1.7.4'; const CARD_VERSION = '1.8.0';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
@@ -144,6 +144,14 @@ class HouseplanCard extends LitElement {
private _areaSel = ''; private _areaSel = '';
private _nameSel = ''; private _nameSel = '';
private _roomDialog = false; private _roomDialog = false;
// зум/панорама плана
private _zoom = 1;
private _pan = { x: 0, y: 0 };
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 _suppressClick = false;
private _infoCard: DevItem | null = null; private _infoCard: DevItem | null = null;
private _markerDialog: { private _markerDialog: {
devId?: string; // редактируемый значок (если есть) devId?: string; // редактируемый значок (если есть)
@@ -191,6 +199,8 @@ class HouseplanCard extends LitElement {
_spaceDialog: { state: true }, _spaceDialog: { state: true },
_infoCard: { state: true }, _infoCard: { state: true },
_markerDialog: { state: true }, _markerDialog: { state: true },
_zoom: { state: true },
_pan: { state: true },
}; };
public connectedCallback(): void { public connectedCallback(): void {
@@ -810,7 +820,7 @@ class HouseplanCard extends LitElement {
private _clickDevice(ev: MouseEvent, d: DevItem): void { private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation(); ev.stopPropagation();
if (this._drag?.moved) return; if (this._drag?.moved || this._suppressClick) return;
if (this._markup) return; if (this._markup) return;
if (this._edit) { if (this._edit) {
this._selId = d.id; this._selId = d.id;
@@ -820,8 +830,120 @@ class HouseplanCard extends LitElement {
this._infoCard = d; this._infoCard = d;
} }
private get _stageEl(): HTMLElement | null {
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)),
};
}
/** Изменить зум с центром в точке (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 };
this._zoom = z;
this._clampPan();
}
private _onWheel(ev: WheelEvent): void {
const stage = this._stageEl;
if (!stage) return;
ev.preventDefault();
const r = stage.getBoundingClientRect();
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor);
}
private _stepZoom(delta: number): void {
const stage = this._stageEl;
if (!stage) return;
this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4));
}
private _resetZoom(): void {
this._zoom = 1;
this._pan = { x: 0, y: 0 };
}
private _stagePointerDown(ev: PointerEvent): void {
// не мешать перетаскиванию иконок и рисованию разметки
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 });
if (this._pointers.size === 1) {
this._panStart = { sx: ev.clientX, sy: ev.clientY, ox: this._pan.x, oy: this._pan.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._panStart = null;
}
}
private _stagePointerMove(ev: PointerEvent): void {
if (!this._pointers.has(ev.pointerId)) {
this._markupMove(ev);
return;
}
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
if (this._pinchStart && 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 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();
this._suppressClick = true;
} 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();
}
}
}
private _stagePointerUp(ev: PointerEvent): void {
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
this._panStart = null;
// сбросить подавление клика на следующий тик (чтобы click после pan не сработал)
setTimeout(() => (this._suppressClick = false), 0);
}
}
private _clickRoom(r: RoomCfg): void { private _clickRoom(r: RoomCfg): void {
if (this._edit || !r.area) return; if (this._suppressClick || this._edit || !r.area) return;
navigate('/config/areas/area/' + r.area); navigate('/config/areas/area/' + r.area);
} }
@@ -840,8 +962,8 @@ 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) * vb[2]; const dx = ((ev.clientX - this._drag.sx) / (rect.width * this._zoom)) * vb[2];
const dy = ((ev.clientY - this._drag.sy) / rect.height) * vb[3]; const dy = ((ev.clientY - this._drag.sy) / (rect.height * this._zoom)) * vb[3];
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));
@@ -927,7 +1049,10 @@ class HouseplanCard extends LitElement {
const stage = this.renderRoot.querySelector('.stage') as HTMLElement; const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
const vb = this._spaceModel().vb; const vb = this._spaceModel().vb;
const r = stage.getBoundingClientRect(); const r = stage.getBoundingClientRect();
return [vb[0] + ((ev.clientX - r.left) / r.width) * vb[2], vb[1] + ((ev.clientY - r.top) / r.height) * vb[3]]; // экран → локальные координаты (отмена 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]];
} }
private _snap(p: number[]): number[] { private _snap(p: number[]): number[] {
@@ -1549,10 +1674,16 @@ class HouseplanCard extends LitElement {
</div> </div>
<span class="count">${devs.length} устр.</span> <span class="count">${devs.length} устр.</span>
<span class="spacer"></span> <span class="spacer"></span>
<div class="zoomctl">
<button class="btn zb" @click=${() => this._stepZoom(-1)} title="Отдалить"><ha-icon icon="mdi:minus"></ha-icon></button>
<button class="btn zb" @click=${() => this._resetZoom()} ?disabled=${this._zoom === 1}
title="Сбросить масштаб"><ha-icon icon="mdi:fit-to-page-outline"></ha-icon></button>
<button class="btn zb" @click=${() => this._stepZoom(1)} title="Приблизить"><ha-icon icon="mdi:plus"></ha-icon></button>
</div>
${this._norm ${this._norm
? html`<button class="btn" @click=${() => this._openMarkerDialog()} ? html`<button class="btn" @click=${() => this._openMarkerDialog()}
title="Добавить устройство на план"> title="Добавить устройство на план">
<ha-icon icon="mdi:plus"></ha-icon> <ha-icon icon="mdi:plus-box-outline"></ha-icon>
</button>` </button>`
: nothing} : nothing}
<button class="btn ${this._edit ? 'on' : ''}" @click=${() => { <button class="btn ${this._edit ? 'on' : ''}" @click=${() => {
@@ -1574,7 +1705,12 @@ class HouseplanCard extends LitElement {
<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]}" style="aspect-ratio:${vb[2]}/${vb[3]}"
@click=${(e: MouseEvent) => this._markupClick(e)} @click=${(e: MouseEvent) => this._markupClick(e)}
@mousemove=${(e: MouseEvent) => this._markupMove(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"> <svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
${this._markup ? this._renderMarkupDefs(vb) : nothing} ${this._markup ? this._renderMarkupDefs(vb) : nothing}
${space.bg ${space.bg
@@ -1603,6 +1739,10 @@ class HouseplanCard extends LitElement {
${devs.map((d) => this._renderDevice(d, vb))} ${devs.map((d) => this._renderDevice(d, vb))}
</div> </div>
</div> </div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
: nothing}
</div>
${this._roomDialog ? this._renderRoomDialog() : nothing} ${this._roomDialog ? this._renderRoomDialog() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing} ${this._spaceDialog ? this._renderSpaceDialog() : nothing}
@@ -2093,6 +2233,42 @@ class HouseplanCard extends LitElement {
position: relative; position: relative;
width: 100%; width: 100%;
container-type: inline-size; container-type: inline-size;
overflow: hidden;
touch-action: none; /* свои жесты pinch/pan */
}
.zoomwrap {
position: absolute;
inset: 0;
transform-origin: 0 0;
will-change: transform;
}
.zoomctl {
display: inline-flex;
gap: 2px;
background: rgba(127, 127, 127, 0.12);
border-radius: 8px;
padding: 2px;
}
.zoomctl .zb {
border: none;
padding: 5px 7px;
}
.zoomctl .zb[disabled] {
opacity: 0.4;
pointer-events: none;
}
.zoombadge {
position: absolute;
left: 8px;
bottom: 8px;
background: rgba(4, 18, 31, 0.8);
color: #dff1ff;
border: 1px solid var(--hp-accent);
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
} }
.stage svg { .stage svg {
position: absolute; position: absolute;