mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -10,7 +10,7 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.7.4"
|
||||
VERSION = "1.8.0"
|
||||
|
||||
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.7.4"
|
||||
"version": "1.8.0"
|
||||
}
|
||||
Vendored
+96
-47
File diff suppressed because one or more lines are too long
@@ -1,5 +1,13 @@
|
||||
# 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)
|
||||
- КОРНЕВАЯ ПРИЧИНА невозможности загрузить PDF: файл слался как base64 одним WebSocket-сообщением,
|
||||
а у WS есть лимит размера — реальный мануал (>2–3 МБ) превышал его, соединение рвалось
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.7.4",
|
||||
"version": "1.8.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+184
-8
@@ -13,7 +13,7 @@ import {
|
||||
} from './logic';
|
||||
import './editor';
|
||||
|
||||
const CARD_VERSION = '1.7.4';
|
||||
const CARD_VERSION = '1.8.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
||||
|
||||
@@ -144,6 +144,14 @@ class HouseplanCard extends LitElement {
|
||||
private _areaSel = '';
|
||||
private _nameSel = '';
|
||||
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 _markerDialog: {
|
||||
devId?: string; // редактируемый значок (если есть)
|
||||
@@ -191,6 +199,8 @@ class HouseplanCard extends LitElement {
|
||||
_spaceDialog: { state: true },
|
||||
_infoCard: { state: true },
|
||||
_markerDialog: { state: true },
|
||||
_zoom: { state: true },
|
||||
_pan: { state: true },
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
@@ -810,7 +820,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _clickDevice(ev: MouseEvent, d: DevItem): void {
|
||||
ev.stopPropagation();
|
||||
if (this._drag?.moved) return;
|
||||
if (this._drag?.moved || this._suppressClick) return;
|
||||
if (this._markup) return;
|
||||
if (this._edit) {
|
||||
this._selId = d.id;
|
||||
@@ -820,8 +830,120 @@ class HouseplanCard extends LitElement {
|
||||
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 {
|
||||
if (this._edit || !r.area) return;
|
||||
if (this._suppressClick || this._edit || !r.area) return;
|
||||
navigate('/config/areas/area/' + r.area);
|
||||
}
|
||||
|
||||
@@ -840,8 +962,8 @@ 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) * vb[2];
|
||||
const dy = ((ev.clientY - this._drag.sy) / rect.height) * vb[3];
|
||||
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];
|
||||
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));
|
||||
@@ -927,7 +1049,10 @@ class HouseplanCard extends LitElement {
|
||||
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
||||
const vb = this._spaceModel().vb;
|
||||
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[] {
|
||||
@@ -1549,10 +1674,16 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
<span class="count">${devs.length} устр.</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
|
||||
? html`<button class="btn" @click=${() => this._openMarkerDialog()}
|
||||
title="Добавить устройство на план">
|
||||
<ha-icon icon="mdi:plus"></ha-icon>
|
||||
<ha-icon icon="mdi:plus-box-outline"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
<button class="btn ${this._edit ? 'on' : ''}" @click=${() => {
|
||||
@@ -1574,7 +1705,12 @@ class HouseplanCard extends LitElement {
|
||||
<div class="stage ${this._edit ? 'edit' : ''} ${this._markup ? 'markup' : ''}"
|
||||
style="aspect-ratio:${vb[2]}/${vb[3]}"
|
||||
@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">
|
||||
${this._markup ? this._renderMarkupDefs(vb) : nothing}
|
||||
${space.bg
|
||||
@@ -1602,6 +1738,10 @@ class HouseplanCard extends LitElement {
|
||||
<div class="devlayer" style="--icon-size:${iconPct}cqw">
|
||||
${devs.map((d) => this._renderDevice(d, vb))}
|
||||
</div>
|
||||
</div>
|
||||
${this._zoom > 1
|
||||
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${this._roomDialog ? this._renderRoomDialog() : nothing}
|
||||
@@ -2093,6 +2233,42 @@ class HouseplanCard extends LitElement {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
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 {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user