mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.4.0 (phase 2): room markup editor — dot grid, two-click lines with snap, closed contour enables save with HA area dropdown, polygon rooms, tools add/erase-line/delete-room, shared wall segments in space.segments
This commit is contained in:
Binary file not shown.
@@ -8,7 +8,7 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
|||||||
PLANS_URL = "/houseplan_files/plans"
|
PLANS_URL = "/houseplan_files/plans"
|
||||||
PLANS_DIR = "houseplan/plans" # относительно каталога конфигурации HA
|
PLANS_DIR = "houseplan/plans" # относительно каталога конфигурации HA
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.3.0"
|
VERSION = "1.4.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -9,5 +9,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.3.0"
|
"version": "1.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,25 @@ POS_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
||||||
|
|
||||||
ROOM_SCHEMA = vol.Schema(
|
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||||
|
ROOM_SCHEMA = vol.All(
|
||||||
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("id"): str,
|
vol.Required("id"): str,
|
||||||
vol.Required("name"): str,
|
vol.Required("name"): str,
|
||||||
vol.Optional("area"): vol.Any(str, None),
|
vol.Optional("area"): vol.Any(str, None),
|
||||||
vol.Required("x"): vol.Coerce(float),
|
# прямоугольная комната (legacy) …
|
||||||
vol.Required("y"): vol.Coerce(float),
|
vol.Optional("x"): vol.Coerce(float),
|
||||||
vol.Required("w"): vol.Coerce(float),
|
vol.Optional("y"): vol.Coerce(float),
|
||||||
vol.Required("h"): vol.Coerce(float),
|
vol.Optional("w"): vol.Coerce(float),
|
||||||
|
vol.Optional("h"): vol.Coerce(float),
|
||||||
|
# … или полигон (редактор разметки)
|
||||||
|
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
),
|
||||||
|
lambda r: r if ("poly" in r or all(k in r for k in ("x", "y", "w", "h")))
|
||||||
|
else (_ for _ in ()).throw(vol.Invalid("room: нужен poly или x/y/w/h")),
|
||||||
)
|
)
|
||||||
SPACE_SCHEMA = vol.Schema(
|
SPACE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -40,6 +48,8 @@ SPACE_SCHEMA = vol.Schema(
|
|||||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||||
|
# сегменты-«стены» (разметочный каркас): [x1,y1,x2,y2], нормированные
|
||||||
|
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
+137
-29
File diff suppressed because one or more lines are too long
@@ -98,6 +98,14 @@ more-info по приоритету доменов), temp, members[] (групп
|
|||||||
Если server config пуст — карточка работает от legacy-бандла (дача) и показывает кнопку
|
Если server config пуст — карточка работает от legacy-бандла (дача) и показывает кнопку
|
||||||
миграции «На сервер» в режиме правки. Дача мигрирована 2026-07-04.
|
миграции «На сервер» в режиме правки. Дача мигрирована 2026-07-04.
|
||||||
|
|
||||||
|
## Редактор разметки (v1.4.0+)
|
||||||
|
|
||||||
|
Состояние в карточке: `_markup` (режим), `_tool` (draw/erase/delroom), `_path` (текущий контур,
|
||||||
|
вершины по сетке GRID_N=60). Клики по stage → `_svgPoint`→`_snap`. Каждая пара точек добавляет
|
||||||
|
сегмент в `space.segments` (дедуп по ключу, сохранение config/set с дебаунсом). Контур замкнут
|
||||||
|
= клик по первой вершине → селект зоны (hass.areas) + имя → room {poly}. Комнаты-полигоны и
|
||||||
|
прямоугольники рендерятся единообразно (hit-test: point-in-polygon / rect).
|
||||||
|
|
||||||
## WS API интеграции
|
## WS API интеграции
|
||||||
|
|
||||||
| Команда | Параметры | Ответ |
|
| Команда | Параметры | Ответ |
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.4.0 — 2026-07-04 (Фаза 2: редактор разметки комнат)
|
||||||
|
- Режим «Разметка» в карточке (кнопка mdi:vector-square-edit, только при server config):
|
||||||
|
сетка точек (60 узлов по ширине), линии парами кликов со снапом к узлам, полилиния-превью.
|
||||||
|
- Замкнутый контур (клик по первой точке) активирует «Сохранить»: выбор зоны HA из
|
||||||
|
выпадающего списка + название; комната сохраняется полигоном (poly, нормированные вершины).
|
||||||
|
- Инструменты: Добавить (рисование), Стереть линию (клик по линии), Удалить комнату
|
||||||
|
(клик внутри + confirm). Линии-«стены» хранятся в space.segments и переиспользуются
|
||||||
|
соседними комнатами (общие стены — кликами по существующим узлам).
|
||||||
|
- Рендер комнат: полигоны наравне с прямоугольниками (hover, клик в зону, LQI-тултип,
|
||||||
|
подпись в центроиде); в режиме разметки контуры и названия всех комнат видимы.
|
||||||
|
- Бэкенд: ROOM_SCHEMA принимает poly (≥3 вершин) или x/y/w/h; SPACE_SCHEMA — segments.
|
||||||
|
|
||||||
## v1.3.0 — 2026-07-04 (Фаза 1: серверная конфигурация)
|
## v1.3.0 — 2026-07-04 (Фаза 1: серверная конфигурация)
|
||||||
- Store `houseplan.config`: spaces (план, aspect, view_box, комнаты), device_overrides
|
- Store `houseplan.config`: spaces (план, aspect, view_box, комнаты), device_overrides
|
||||||
(hidden/icon/name), virtual_devices, settings (exclude_integrations, group_lights).
|
(hidden/icon/name), virtual_devices, settings (exclude_integrations, group_lights).
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@
|
|||||||
- Единицы координат: нормированные (0..1 от плана) для новых конфигов — независимость от
|
- Единицы координат: нормированные (0..1 от плана) для новых конфигов — независимость от
|
||||||
разрешения исходника; миграция дачи пересчитает 1489×1053 → нормированные.
|
разрешения исходника; миграция дачи пересчитает 1489×1053 → нормированные.
|
||||||
|
|
||||||
## Фаза 2 — Редактор разметки в карточке
|
## Фаза 2 — Редактор разметки в карточке ← ✅ ЯДРО СДЕЛАНО в v1.4.0 (осталось: загрузка плана из UI, правка view_box, редактирование существующих комнат)
|
||||||
- Режим «Настройка» (отдельно от drag-раскладки): рисование/ресайз прямоугольников комнат
|
- Режим «Настройка» (отдельно от drag-раскладки): рисование/ресайз прямоугольников комнат
|
||||||
поверх плана, привязка к area (селектор ha-area-picker), редактирование viewBox (кадр).
|
поверх плана, привязка к area (селектор ha-area-picker), редактирование viewBox (кадр).
|
||||||
- Позже: полигональные комнаты (SVG path), повороты планов.
|
- Позже: полигональные комнаты (SVG path), повороты планов.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.3.0",
|
"version": "1.4.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",
|
||||||
|
|||||||
+392
-21
@@ -12,7 +12,7 @@ import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
|
|||||||
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
|
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
|
||||||
import './editor';
|
import './editor';
|
||||||
|
|
||||||
const CARD_VERSION = '1.3.0';
|
const CARD_VERSION = '1.4.0';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
||||||
|
|
||||||
@@ -26,12 +26,16 @@ interface RoomCfg {
|
|||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
area: string | null;
|
area: string | null;
|
||||||
x: number;
|
x?: number;
|
||||||
y: number;
|
y?: number;
|
||||||
w: number;
|
w?: number;
|
||||||
h: number;
|
h?: number;
|
||||||
|
poly?: number[][]; // полигон в рендер-единицах (модель) / нормированный (конфиг)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GRID_N = 60; // точек сетки по ширине плана
|
||||||
|
type MarkupTool = 'draw' | 'erase' | 'delroom';
|
||||||
|
|
||||||
interface SpaceModel {
|
interface SpaceModel {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -131,6 +135,14 @@ class HouseplanCard extends LitElement {
|
|||||||
private _toastTimer?: number;
|
private _toastTimer?: number;
|
||||||
private _migrating = false;
|
private _migrating = false;
|
||||||
|
|
||||||
|
// --- редактор разметки комнат ---
|
||||||
|
private _markup = false;
|
||||||
|
private _tool: MarkupTool = 'draw';
|
||||||
|
private _path: number[][] = []; // текущий контур (рендер-единицы, вершины по сетке)
|
||||||
|
private _cursorPt: number[] | null = null;
|
||||||
|
private _areaSel = '';
|
||||||
|
private _nameSel = '';
|
||||||
|
|
||||||
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
||||||
|
|
||||||
static properties = {
|
static properties = {
|
||||||
@@ -146,6 +158,12 @@ class HouseplanCard extends LitElement {
|
|||||||
_toast: { state: true },
|
_toast: { state: true },
|
||||||
_serverCfg: { state: true },
|
_serverCfg: { state: true },
|
||||||
_migrating: { state: true },
|
_migrating: { state: true },
|
||||||
|
_markup: { state: true },
|
||||||
|
_tool: { state: true },
|
||||||
|
_path: { state: true },
|
||||||
|
_cursorPt: { state: true },
|
||||||
|
_areaSel: { state: true },
|
||||||
|
_nameSel: { state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
public static getConfigElement() {
|
public static getConfigElement() {
|
||||||
@@ -180,10 +198,11 @@ class HouseplanCard extends LitElement {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
area: r.area ?? null,
|
area: r.area ?? null,
|
||||||
x: r.x * NORM_W,
|
x: r.x != null ? r.x * NORM_W : undefined,
|
||||||
y: r.y * H,
|
y: r.y != null ? r.y * H : undefined,
|
||||||
w: r.w * NORM_W,
|
w: r.w != null ? r.w * NORM_W : undefined,
|
||||||
h: r.h * H,
|
h: r.h != null ? r.h * H : undefined,
|
||||||
|
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -665,6 +684,203 @@ class HouseplanCard extends LitElement {
|
|||||||
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
|
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================= РЕДАКТОР РАЗМЕТКИ КОМНАТ =================
|
||||||
|
|
||||||
|
private get _gridPitch(): number {
|
||||||
|
return NORM_W / GRID_N;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _curSpaceCfg(): any {
|
||||||
|
return this._serverCfg?.spaces.find((s: any) => s.id === this._space);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _spaceH(): number {
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
return sp ? NORM_W / sp.aspect : NORM_W;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сегменты текущего пространства в рендер-единицах. */
|
||||||
|
private get _segments(): number[][] {
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
const H = this._spaceH;
|
||||||
|
return (sp?.segments || []).map((s: number[]) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toggleMarkup(): void {
|
||||||
|
if (!this._norm) {
|
||||||
|
this._showToast('Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._markup = !this._markup;
|
||||||
|
this._edit = false;
|
||||||
|
this._path = [];
|
||||||
|
this._cursorPt = null;
|
||||||
|
this._tool = 'draw';
|
||||||
|
}
|
||||||
|
|
||||||
|
private _svgPoint(ev: MouseEvent): number[] {
|
||||||
|
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]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _snap(p: number[]): number[] {
|
||||||
|
const g = this._gridPitch;
|
||||||
|
return [Math.round(p[0] / g) * g, Math.round(p[1] / g) * g];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _samePt(a: number[], b: number[]): boolean {
|
||||||
|
return Math.abs(a[0] - b[0]) < 0.001 && Math.abs(a[1] - b[1]) < 0.001;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _segKey(a: number[], b: number[]): string {
|
||||||
|
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||||
|
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _saveConfig = debounce(() => {
|
||||||
|
if (!this._serverCfg) return;
|
||||||
|
this.hass
|
||||||
|
.callWS({ type: 'houseplan/config/set', config: this._serverCfg })
|
||||||
|
.catch((e: any) => this._showToast('Не удалось сохранить конфиг: ' + (e?.message || e)));
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
/** Добавить сегмент (рендер-единицы) в каркас пространства (без дублей). */
|
||||||
|
private _addSegment(a: number[], b: number[]): void {
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
if (!sp) return;
|
||||||
|
const H = this._spaceH;
|
||||||
|
const key = this._segKey(a, b);
|
||||||
|
const exists = this._segments.some((s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key);
|
||||||
|
if (exists) return;
|
||||||
|
sp.segments = sp.segments || [];
|
||||||
|
sp.segments.push([a[0] / NORM_W, a[1] / H, b[0] / NORM_W, b[1] / H]);
|
||||||
|
this._saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _distToSeg(p: number[], s: number[]): number {
|
||||||
|
const [x, y] = p;
|
||||||
|
const [x1, y1, x2, y2] = s;
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const len2 = dx * dx + dy * dy || 1;
|
||||||
|
let t = ((x - x1) * dx + (y - y1) * dy) / len2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const px = x1 + t * dx;
|
||||||
|
const py = y1 + t * dy;
|
||||||
|
return Math.hypot(x - px, y - py);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _pointInRoom(p: number[], r: RoomCfg): boolean {
|
||||||
|
if (r.poly) {
|
||||||
|
let inside = false;
|
||||||
|
const poly = r.poly;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const [xi, yi] = poly[i];
|
||||||
|
const [xj, yj] = poly[j];
|
||||||
|
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _markupClick(ev: MouseEvent): void {
|
||||||
|
if (!this._markup) return;
|
||||||
|
const raw = this._svgPoint(ev);
|
||||||
|
if (this._tool === 'erase') {
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
if (!sp?.segments?.length) return;
|
||||||
|
const segs = this._segments;
|
||||||
|
let best = -1;
|
||||||
|
let bestD = this._gridPitch * 0.5;
|
||||||
|
segs.forEach((s, i) => {
|
||||||
|
const d = this._distToSeg(raw, s);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (best >= 0) {
|
||||||
|
sp.segments.splice(best, 1);
|
||||||
|
this._saveConfig();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._tool === 'delroom') {
|
||||||
|
const space = this._spaceModel();
|
||||||
|
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||||||
|
if (!room) return;
|
||||||
|
if (!confirm(`Удалить комнату «${room.name}»?`)) return;
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id);
|
||||||
|
this._saveConfig();
|
||||||
|
this._regSignature = '';
|
||||||
|
this._maybeRebuildDevices();
|
||||||
|
this.requestUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// draw: клики по точкам сетки, пары точек соединяются линией
|
||||||
|
const pt = this._snap(raw);
|
||||||
|
if (!this._path.length) {
|
||||||
|
this._path = [pt];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const last = this._path[this._path.length - 1];
|
||||||
|
if (this._samePt(pt, last)) return; // повторный клик по той же точке
|
||||||
|
this._addSegment(last, pt);
|
||||||
|
// замыкание: клик по первой вершине
|
||||||
|
if (this._path.length >= 3 && this._samePt(pt, this._path[0])) {
|
||||||
|
this._path = [...this._path]; // контур закрыт — path остаётся, Save активен
|
||||||
|
this._cursorPt = null;
|
||||||
|
this._path.push(pt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._path = [...this._path, pt];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _contourClosed(): boolean {
|
||||||
|
return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _markupMove(ev: MouseEvent): void {
|
||||||
|
if (!this._markup || this._tool !== 'draw' || !this._path.length || this._contourClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._cursorPt = this._snap(this._svgPoint(ev));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _saveRoom(): void {
|
||||||
|
if (!this._contourClosed || !this._areaSel && !this._nameSel) return;
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
if (!sp) return;
|
||||||
|
const H = this._spaceH;
|
||||||
|
const verts = this._path.slice(0, -1); // без дублированной замыкающей
|
||||||
|
const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : '';
|
||||||
|
sp.rooms.push({
|
||||||
|
id: 'r' + Date.now().toString(36),
|
||||||
|
name: this._nameSel || areaName || 'Комната',
|
||||||
|
area: this._areaSel || null,
|
||||||
|
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
|
||||||
|
});
|
||||||
|
this._saveConfig();
|
||||||
|
this._path = [];
|
||||||
|
this._areaSel = '';
|
||||||
|
this._nameSel = '';
|
||||||
|
this._regSignature = '';
|
||||||
|
this._maybeRebuildDevices();
|
||||||
|
this._showToast('Комната сохранена');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cancelPath(): void {
|
||||||
|
this._path = [];
|
||||||
|
this._cursorPt = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ================= МИГРАЦИЯ legacy → сервер =================
|
// ================= МИГРАЦИЯ legacy → сервер =================
|
||||||
|
|
||||||
private async _migrateToServer(): Promise<void> {
|
private async _migrateToServer(): Promise<void> {
|
||||||
@@ -779,29 +995,44 @@ class HouseplanCard extends LitElement {
|
|||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn ${this._edit ? 'on' : ''}" @click=${() => {
|
<button class="btn ${this._edit ? 'on' : ''}" @click=${() => {
|
||||||
this._edit = !this._edit;
|
this._edit = !this._edit;
|
||||||
|
this._markup = false;
|
||||||
this._selId = null;
|
this._selId = null;
|
||||||
}} title="Режим правки: перетаскивание иконок">
|
}} title="Режим правки: перетаскивание иконок">
|
||||||
<ha-icon icon="mdi:cursor-move"></ha-icon>
|
<ha-icon icon="mdi:cursor-move"></ha-icon>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
|
||||||
|
title="Разметка комнат: сетка, линии, контуры">
|
||||||
|
<ha-icon icon="mdi:vector-square-edit"></ha-icon>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stage ${this._edit ? 'edit' : ''}" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
<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)}>
|
||||||
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
|
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
|
||||||
|
${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" />`
|
||||||
: nothing}
|
: nothing}
|
||||||
${space.rooms.filter((r) => r.area).map(
|
${space.rooms.filter((r) => r.area || this._markup).map((r) => {
|
||||||
(r) => svg`<rect
|
const cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||||
class="room ${space.bg ? 'overlay' : 'yard'}"
|
const tip = (e: MouseEvent) =>
|
||||||
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w, r.h) * 0.03}"
|
|
||||||
@click=${() => this._clickRoom(r)}
|
|
||||||
@mousemove=${(e: MouseEvent) =>
|
|
||||||
this._showTip(e, r.name, 'комната — открыть зону',
|
this._showTip(e, r.name, 'комната — открыть зону',
|
||||||
this._config?.show_signal ? this._roomLqi(r.area) : null)}
|
this._config?.show_signal ? this._roomLqi(r.area) : null);
|
||||||
@mouseleave=${() => (this._tip = null)}
|
const label = !space.bg || this._markup;
|
||||||
></rect>
|
const c = this._roomCenter(r);
|
||||||
${!space.bg ? svg`<text class="rlabel" x="${r.x + r.w / 2}" y="${r.y + Math.min(r.w, r.h) * 0.1}">${r.name}</text>` : nothing}`,
|
const shape = r.poly
|
||||||
)}
|
? svg`<polygon class="${cls}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
|
||||||
|
@click=${() => this._clickRoom(r)} @mousemove=${tip}
|
||||||
|
@mouseleave=${() => (this._tip = null)}></polygon>`
|
||||||
|
: svg`<rect class="${cls}"
|
||||||
|
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
|
||||||
|
@click=${() => this._clickRoom(r)} @mousemove=${tip}
|
||||||
|
@mouseleave=${() => (this._tip = null)}></rect>`;
|
||||||
|
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||||
|
})}
|
||||||
|
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||||
</svg>
|
</svg>
|
||||||
<div class="devlayer" style="--icon-size:${iconPct}cqw">
|
<div class="devlayer" style="--icon-size:${iconPct}cqw">
|
||||||
${devs.map((d) => this._renderDevice(d, vb))}
|
${devs.map((d) => this._renderDevice(d, vb))}
|
||||||
@@ -809,6 +1040,7 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this._edit ? this._renderEditbar() : nothing}
|
${this._edit ? this._renderEditbar() : nothing}
|
||||||
|
${this._markup ? this._renderMarkupBar() : nothing}
|
||||||
${this._menuDev ? this._renderMenu() : nothing}
|
${this._menuDev ? this._renderMenu() : nothing}
|
||||||
${this._tip
|
${this._tip
|
||||||
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
|
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
|
||||||
@@ -849,6 +1081,88 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _roomCenter(r: RoomCfg): number[] {
|
||||||
|
if (r.poly) {
|
||||||
|
const n = r.poly.length;
|
||||||
|
return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n];
|
||||||
|
}
|
||||||
|
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderMarkupDefs(vb: number[]): TemplateResult {
|
||||||
|
const g = this._gridPitch;
|
||||||
|
const dotR = g * 0.09;
|
||||||
|
return svg`<defs>
|
||||||
|
<pattern id="hp-grid" x="0" y="0" width="${g}" height="${g}" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="0" cy="0" r="${dotR}" class="griddot"></circle>
|
||||||
|
<circle cx="${g}" cy="0" r="${dotR}" class="griddot"></circle>
|
||||||
|
<circle cx="0" cy="${g}" r="${dotR}" class="griddot"></circle>
|
||||||
|
<circle cx="${g}" cy="${g}" r="${dotR}" class="griddot"></circle>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderMarkupLayer(_vb: number[]): TemplateResult {
|
||||||
|
const segs = this._segments;
|
||||||
|
const path = this._path;
|
||||||
|
const g = this._gridPitch;
|
||||||
|
return svg`
|
||||||
|
${segs.map((s) => svg`<line class="seg" x1="${s[0]}" y1="${s[1]}" x2="${s[2]}" y2="${s[3]}"></line>`)}
|
||||||
|
${path.length > 1
|
||||||
|
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
|
||||||
|
: nothing}
|
||||||
|
${path.length && this._cursorPt && this._tool === 'draw' && !this._contourClosed
|
||||||
|
? svg`<line class="preview" x1="${path[path.length - 1][0]}" y1="${path[path.length - 1][1]}"
|
||||||
|
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
|
||||||
|
: nothing}
|
||||||
|
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderMarkupBar(): TemplateResult {
|
||||||
|
const areas = Object.values<any>(this.hass?.areas || {}).sort((a, b) =>
|
||||||
|
(a.name || '').localeCompare(b.name || ''),
|
||||||
|
);
|
||||||
|
const closed = this._contourClosed;
|
||||||
|
return html`<div class="editbar">
|
||||||
|
<ha-icon icon="mdi:vector-square-edit" class="warn"></ha-icon>
|
||||||
|
<button class="btn ${this._tool === 'draw' ? 'on' : ''}" @click=${() => (this._tool = 'draw')}
|
||||||
|
title="Добавить комнату: соединяйте точки сетки линиями до замкнутого контура">
|
||||||
|
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>Добавить
|
||||||
|
</button>
|
||||||
|
<button class="btn ${this._tool === 'erase' ? 'on' : ''}" @click=${() => (this._tool = 'erase')}
|
||||||
|
title="Стереть линию: клик по линии">
|
||||||
|
<ha-icon icon="mdi:eraser"></ha-icon>Стереть
|
||||||
|
</button>
|
||||||
|
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
|
||||||
|
title="Удалить комнату: клик внутри комнаты">
|
||||||
|
<ha-icon icon="mdi:delete-outline"></ha-icon>Удалить
|
||||||
|
</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
${this._tool === 'draw'
|
||||||
|
? closed
|
||||||
|
? html`<select class="areasel" .value=${this._areaSel}
|
||||||
|
@change=${(e: Event) => (this._areaSel = (e.target as HTMLSelectElement).value)}>
|
||||||
|
<option value="">— зона HA —</option>
|
||||||
|
${areas.map((a) => html`<option value=${a.area_id} ?selected=${a.area_id === this._areaSel}>${a.name}</option>`)}
|
||||||
|
</select>
|
||||||
|
<input class="namein" type="text" placeholder="Название"
|
||||||
|
.value=${this._nameSel}
|
||||||
|
@input=${(e: Event) => (this._nameSel = (e.target as HTMLInputElement).value)} />
|
||||||
|
<button class="btn on" @click=${this._saveRoom}
|
||||||
|
?disabled=${!this._areaSel && !this._nameSel}>
|
||||||
|
<ha-icon icon="mdi:check"></ha-icon>Сохранить
|
||||||
|
</button>
|
||||||
|
<button class="btn ghost" @click=${this._cancelPath}>Отмена</button>`
|
||||||
|
: html`<span class="hint">${this._path.length
|
||||||
|
? 'точек: ' + this._path.length + ' — замкните контур кликом по первой точке'
|
||||||
|
: 'кликните точку сетки, чтобы начать контур'}</span>
|
||||||
|
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>Сброс</button>` : nothing}`
|
||||||
|
: nothing}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
private _renderEditbar(): TemplateResult {
|
private _renderEditbar(): TemplateResult {
|
||||||
const sel = this._selId ? this._devices.find((d) => d.id === this._selId) : null;
|
const sel = this._selId ? this._devices.find((d) => d.id === this._selId) : null;
|
||||||
const p = sel ? this._pos(sel) : null;
|
const p = sel ? this._pos(sel) : null;
|
||||||
@@ -1059,6 +1373,63 @@ class HouseplanCard extends LitElement {
|
|||||||
.stage.edit .room {
|
.stage.edit .room {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.stage.markup {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.stage.markup .room {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.stage.markup .devlayer {
|
||||||
|
display: none; /* в разметке иконки не мешают */
|
||||||
|
}
|
||||||
|
.room.outlined {
|
||||||
|
stroke: rgba(62, 166, 255, 0.55);
|
||||||
|
fill: rgba(62, 166, 255, 0.06);
|
||||||
|
}
|
||||||
|
.griddot {
|
||||||
|
fill: var(--hp-accent);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
stroke: var(--hp-accent);
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.pathline {
|
||||||
|
stroke: #ffc14d;
|
||||||
|
stroke-width: 3;
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
stroke: #ffc14d;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 6 5;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.vertex {
|
||||||
|
fill: #ffc14d;
|
||||||
|
stroke: #4a2800;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.vertex.first {
|
||||||
|
fill: #4bd28f;
|
||||||
|
stroke: #04121f;
|
||||||
|
}
|
||||||
|
.areasel,
|
||||||
|
.namein {
|
||||||
|
background: var(--hp-bg);
|
||||||
|
border: 1px solid var(--hp-line);
|
||||||
|
color: var(--hp-txt);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.namein {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
.devlayer {
|
.devlayer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user