feat v1.8.1: fit-to-viewport (весь план виден по ширине и высоте) + сохранение зума по пространствам в localStorage

This commit is contained in:
JB
2026-07-04 20:27:52 +03:00
parent 98dbb1534d
commit 677e417771
7 changed files with 95 additions and 39 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.0" VERSION = "1.8.1"
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.0" "version": "1.8.1"
} }
+17 -16
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## v1.8.1 — 2026-07-04 (fit-to-viewport + сохранение зума по пространствам)
- Уменьшение масштаба до полного вида: нижний предел зума = 100% совпадает с полностью
видимым планом. `.stage` теперь ограничена по высоте вьюпорта
(`width:min(100%, calc((100dvh 118px) × aspect))`, `max-width:100%`, центрирование),
поэтому при сбросе/зум-ауте вся схема видна и по ширине, и по высоте без обрезки.
- Масштаб запоминается для каждого пространства отдельно и локально на устройстве
(`localStorage['houseplan_card_zoom_v1']`, `_zoomBySpace`): переключение этажей
восстанавливает свой зум; при возврате пан центрируется и ограничивается.
- Проверено на живом HA: f1→196%, f2→140%, двор нетронут — после переключений
значения восстанавливаются; сброс даёт 100% и полный план (874≤992 px по высоте).
## v1.8.0 — 2026-07-04 (зумирование плана) ## v1.8.0 — 2026-07-04 (зумирование плана)
- Зум и панорама схемы: колёсико мыши (к курсору), щипок двумя пальцами (pinch) на тач-экране, - Зум и панорама схемы: колёсико мыши (к курсору), щипок двумя пальцами (pinch) на тач-экране,
перетаскивание фона одним пальцем при увеличении, кнопки −/сброс/+ в тулбаре, бейдж масштаба. перетаскивание фона одним пальцем при увеличении, кнопки −/сброс/+ в тулбаре, бейдж масштаба.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.8.0", "version": "1.8.1",
"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",
+46 -4
View File
@@ -13,8 +13,9 @@ import {
} from './logic'; } from './logic';
import './editor'; import './editor';
const CARD_VERSION = '1.8.0'; const CARD_VERSION = '1.8.1';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_ZOOM = 'houseplan_card_zoom_v1';
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
interface RoomCfg { interface RoomCfg {
@@ -144,9 +145,10 @@ class HouseplanCard extends LitElement {
private _areaSel = ''; private _areaSel = '';
private _nameSel = ''; private _nameSel = '';
private _roomDialog = false; private _roomDialog = false;
// зум/панорама плана // зум/панорама плана (зум сохраняется по пространству локально)
private _zoom = 1; private _zoom = 1;
private _pan = { x: 0, y: 0 }; private _pan = { x: 0, y: 0 };
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; ox: number; oy: 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; cx: number; cy: number; px: number; py: number } | null = null;
@@ -273,6 +275,11 @@ class HouseplanCard extends LitElement {
public setConfig(config: CardConfig): void { public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config }; this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (config.default_floor) this._space = config.default_floor; if (config.default_floor) this._space = config.default_floor;
try {
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
} catch {
this._zoomBySpace = {};
}
} }
public getCardSize(): number { public getCardSize(): number {
@@ -365,6 +372,7 @@ class HouseplanCard extends LitElement {
if (this._norm && !this._model.find((s) => s.id === this._space)) { if (this._norm && !this._model.find((s) => s.id === this._space)) {
this._space = this._model[0]?.id || this._space; this._space = this._model[0]?.id || this._space;
} }
this._restoreZoom();
} catch (e) { } catch (e) {
// не последняя попытка — молча ждём следующего обновления hass (прогрев WS) // не последняя попытка — молча ждём следующего обновления hass (прогрев WS)
if (this._loadTries >= 8) { if (this._loadTries >= 8) {
@@ -866,17 +874,48 @@ class HouseplanCard extends LitElement {
const r = stage.getBoundingClientRect(); const r = stage.getBoundingClientRect();
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15; const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor); this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor);
this._saveZoom();
} }
private _stepZoom(delta: number): void { private _stepZoom(delta: number): void {
const stage = this._stageEl; const stage = this._stageEl;
if (!stage) return; if (!stage) return;
this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4)); this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4));
this._saveZoom();
} }
private _resetZoom(): void { private _resetZoom(): void {
this._zoom = 1; this._zoom = 1;
this._pan = { x: 0, y: 0 }; this._pan = { x: 0, y: 0 };
this._saveZoom();
}
/** Сохранить текущий зум пространства в localStorage. */
private _saveZoom(): void {
this._zoomBySpace = { ...this._zoomBySpace, [this._space]: this._zoom };
try {
localStorage.setItem(LS_ZOOM, JSON.stringify(this._zoomBySpace));
} catch {
/* ignore */
}
}
/** Восстановить сохранённый зум пространства и центрировать. */
private _restoreZoom(): void {
this._zoom = this._zoomBySpace[this._space] || 1;
this._pan = { x: 0, y: 0 };
// центрировать после применения размеров
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();
}
});
} }
private _stagePointerDown(ev: PointerEvent): void { private _stagePointerDown(ev: PointerEvent): void {
@@ -921,6 +960,7 @@ class HouseplanCard extends LitElement {
this._zoom = z; this._zoom = z;
this._clampPan(); this._clampPan();
this._suppressClick = true; this._suppressClick = true;
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;
@@ -1653,6 +1693,7 @@ class HouseplanCard extends LitElement {
@click=${() => { @click=${() => {
this._space = s.id; this._space = s.id;
this._selId = null; this._selId = null;
this._restoreZoom();
}} }}
> >
${s.title}${this._norm ${s.title}${this._norm
@@ -1703,7 +1744,7 @@ 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]}" style="aspect-ratio:${vb[2]}/${vb[3]};width:min(100%,calc((100dvh - 118px) * ${(vb[2] / vb[3]).toFixed(4)}))"
@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)}
@@ -2231,7 +2272,8 @@ class HouseplanCard extends LitElement {
} }
.stage { .stage {
position: relative; position: relative;
width: 100%; max-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 */