Merge dev: v1.38.3..v1.40.1 (dashed walls in editor, default light toggle, smart guides, room link)

This commit is contained in:
Matysh
2026-07-23 18:19:48 +03:00
19 changed files with 715 additions and 128 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
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.38.2" VERSION = "1.40.1"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.38.2" "version": "1.40.1"
} }
+56
View File
@@ -0,0 +1,56 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const g = c._gridPitch;
const guides = () => sr().querySelectorAll('.alignline').length;
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
const v = poly[0];
c._path = [[v[0] + g * 10, v[1] + g * 10]];
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
c.requestUpdate(); await c.updateComplete;
out.drawGuide = guides() >= 1;
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
// нет выравнивания → нет гидов
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
out.noGuideOffAxis = guides() === 0;
// 2) бейдж угла: 45° красит
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
const ml = sr().querySelector('.measurelabel');
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
const ml2 = sr().querySelector('.measurelabel');
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
c._path = []; c._cursorPt = null;
// 3) редактор устройств: drag значка в линию с другим значком
c._setMode('devices'); await c.updateComplete;
const devs = c._devices.filter((d) => d.space === c._space);
const a = devs[0], b = devs[1];
const pa = c._pos(a);
// поставим b на тот же Y, начнём drag
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
c.requestUpdate(); await c.updateComplete;
out.devGuide = guides() >= 1;
c._drag = null; c.requestUpdate(); await c.updateComplete;
out.devGuideGone = guides() === 0;
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
c._setMode('decor'); await c.updateComplete;
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
c.requestUpdate(); await c.updateComplete;
out.decorGuide = guides() >= 1;
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
// 5) в Просмотре гидов не бывает
c._setMode('view'); await c.updateComplete;
out.noneInView = guides() === 0;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+34
View File
@@ -0,0 +1,34 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
// лампа (primary light) без явного действия → клик = toggle
const lamp = c._devices.find((d) => d.primary?.startsWith('light.') && !d.tapAction);
out.hasLamp = !!lamp;
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), lamp);
out.lampToggles = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'toggle', lamp.primary]);
// устройство с не-light primary (сенсор) → клик = инфо
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
const n = calls.length;
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), sensorDev);
out.sensorInfo = calls.length === n && !!c._infoCard;
c._infoCard = null;
// явный info у лампы отключает дефолт
const cfgm = { id: lamp.id, binding: lamp.bindingKind + ':' + lamp.bindingRef, tap_action: 'info' };
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []).filter((m) => m.id !== lamp.id), cfgm] };
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const lamp2 = c._devices.find((d) => d.id === lamp.id);
const n2 = calls.length;
c._clickDevice(new MouseEvent('click'), lamp2);
out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+14 -1
View File
@@ -24,7 +24,20 @@ const res = await page.evaluate(async () => {
const gi = order.indexOf('glowlayer'); const gi = order.indexOf('glowlayer');
const oi = order.indexOf('openwalls'); const oi = order.indexOf('openwalls');
out.dashAboveGlow = gi === -1 || oi > gi; out.dashAboveGlow = gi === -1 || oi > gi;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete; // в редакторе плана: пунктир виден, штрих комнат вырезан, контур синий
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
out.planDashes = sr().querySelectorAll('.openwall').length > 0;
out.planNoedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.planBlueOutline = sr().querySelectorAll('.room-outline.outlined').length >= 2;
// производные стены (.seg) не проходят сквозь открытый участок x=550, y≈0.25H
const midY = 0.25 * H;
out.planSegCut = ![...sr().querySelectorAll('line.seg')].some((l) => {
const x1 = +l.getAttribute('x1'), x2 = +l.getAttribute('x2');
const y1 = +l.getAttribute('y1'), y2 = +l.getAttribute('y2');
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
});
c._tool = 'openwall'; await c.updateComplete;
// повторный клик закрывает // повторный клик закрывает
c._openWallClick([550, 0.25 * H]); await c.updateComplete; c._openWallClick([550, 0.25 * H]); await c.updateComplete;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2'); out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
+38
View File
@@ -0,0 +1,38 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// включить имена комнат
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
// 1) комната не кликабельна: cursor default, обработчика клика нет
const room = sr().querySelector('.room');
out.roomCursor = getComputedStyle(room).cursor === 'default';
// 2) значок ссылки у комнат с зоной, кликабелен, ведёт в зону
const links = sr().querySelectorAll('.rlgo');
out.linksShown = links.length > 0;
const withArea = c._spaceModel().rooms.filter((r) => r.name && r.area).length;
out.linkPerArearoom = links.length === withArea;
const icon = links[0];
out.linkCursor = getComputedStyle(icon).cursor === 'pointer';
out.linkClickable = getComputedStyle(icon).pointerEvents === 'auto';
// навигация: перехват history
let navTo = null;
const orig = history.pushState.bind(history);
history.pushState = (a, b, url) => { navTo = url; };
icon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = orig;
out.navigates = typeof navTo === 'string' && navTo.includes('/config/areas/area/');
// 3) комнаты без зоны — без значка (проверено соотношением выше); в разметке значков нет
c._setMode('plan'); await c.updateComplete;
out.noneInPlan = sr().querySelectorAll('.rlgo').length === 0;
c._setMode('view'); await c.updateComplete;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+72 -32
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -1,5 +1,43 @@
# Changelog # Changelog
## v1.40.1 — 2026-07-23
- Rooms are no longer clickable in View (default cursor, empty space does
nothing). Instead the room card shows a small **open-in-new icon** after
the name — clicking it opens the HA area. Rooms without an area (and all
editors) have no icon.
## v1.40.0 — 2026-07-23 (smart guides)
- **Alignment helper in every editor**: while drawing an outline, a cut or a
decor shape, and while dragging icons, room cards or decor, thin dashed
guides appear from the nearest object sharing your X and/or Y (one per
axis, with a marker dot at the source) — lamps line up, lines end exactly
above the end of a parallel line. Candidates follow the context: room
vertices and path points in the Plan editor, other icons in the Device
editor, decor endpoints/corners plus room vertices in the Background
editor, other room cards while dragging one.
- The cursor badge now shows **length · angle** and turns green when the
segment's angle is a multiple of 45°. Guides are pure indication — the
grid keeps owning the actual position.
## v1.39.0 — 2026-07-23 (lights toggle by default)
- Pure light sources — devices whose primary entity is a `light` (bulbs,
chandeliers, night lights, light groups) — now **toggle on click by
default**, right from auto-placement, no per-device setting needed.
Devices where light is a side function (a kettle's backlight: its primary
is a sensor) keep the Device-card default. An explicit per-device choice
always wins. The device dialog shows the effective default.
## v1.38.4 — 2026-07-23
- Plan editor: the DERIVED wall segments (the markup layer's solid lines)
are now trimmed under open boundaries as well — v1.38.3 only trimmed the
room outlines, so the virtual wall still looked solid in the editor.
(`cutSegments` extracted as the shared workhorse; outlineWithout reuses it.)
## v1.38.3 — 2026-07-23
- The Plan editor now shows open boundaries as a true dash as well: the blue
markup outlines are trimmed under the open stretches (rooms picked for
merge/split keep their full amber highlight).
## v1.38.2 — 2026-07-23 ## v1.38.2 — 2026-07-23
- The card now **remembers where you were**: the selected space and the active - The card now **remembers where you were**: the selected space and the active
editor survive navigation and closing the tab (localStorage; edit modes are editor survive navigation and closing the tab (localStorage; edit modes are
+21
View File
@@ -140,6 +140,27 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Room link icon (v1.40.1): clicking empty room space in View does
nothing (default cursor); an open-in-new icon after the room name (rooms
with an HA area, View only) navigates to the area; no icon in editors or
on area-less rooms [auto]
- [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or
dragging (icons, room cards, decor) dashed accent guides appear from the
nearest object sharing the X and/or Y (max two, with a dot at the
source); the cursor badge shows length · angle and turns green on 45°
multiples; indication only — no magnetism; nothing in View mode [auto]
- [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
light (bulbs, chandeliers, night lights, light groups) toggles on click
out of the box — no per-device setting needed; the device dialog shows
"Toggle" as its effective default; devices where light is a side
function (kettle: primary = sensor) keep the Device-card default;
explicit per-device "Device card" wins over the default [auto]
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
segments (.seg) no longer run solid through an open stretch — only the
dash remains there [auto]
- [ ] Dashed boundaries in the Plan editor (v1.38.3): open stretches render as
a true dash in markup too (blue trimmed outlines); merge/split-picked
rooms keep their full amber highlight [auto]
- [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last - [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last
space AND editor mode (admins; localStorage); a #space= deep link beats space AND editor mode (admins; localStorage); a #space= deep link beats
the saved space; a stale cache without the saved space retries after the the saved space; a stale cache without the saved space retries after the
+1 -1
View File
@@ -35,7 +35,7 @@ Allowed: pan/zoom (wheel, pinch, buttons), switching spaces, device tap
(info / more-info / toggle per settings), long-press → info card, opening tap → (info / more-info / toggle per settings), long-press → info card, opening tap →
door/lock info card (with an explicit Unlock/Lock button when a lock is bound — door/lock info card (with an explicit Unlock/Lock button when a lock is bound —
the only way to operate a lock from the card; plan-icon taps never toggle locks), the only way to operate a lock from the card; plan-icon taps never toggle locks),
room tap → HA area, hover tooltips (name, temperature, signal). room-card link icon → HA area (room taps do nothing since v1.40.1), hover tooltips (name, temperature, signal).
Removed from this mode (they move, not die): Removed from this mode (they move, not die):
- icon dragging ("drag anywhere", v1.9 — consciously reversed), - icon dragging ("drag anywhere", v1.9 — consciously reversed),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.38.2", "version": "1.40.1",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+145 -15
View File
@@ -14,7 +14,7 @@ import {
import { import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.38.2'; const CARD_VERSION = '1.40.1';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -169,7 +169,8 @@ class HouseplanCard extends LitElement {
rippleSize: number; // in icon diameters rippleSize: number; // in icon diameters
size: number; // icon size multiplier size: number; // icon size multiplier
angle: number; // icon rotation, degrees angle: number; // icon rotation, degrees
tapAction: string; // '' = card default tapAction: string; // '' = the effective default (defaultTap)
defaultTap: 'info' | 'toggle';
controls: string[]; // entities this icon toggles as a group controls: string[]; // entities this icon toggles as a group
controlsFilter: string; controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default glowRadius: string; // per-device glow radius in display units; '' = global default
@@ -2151,6 +2152,7 @@ class HouseplanCard extends LitElement {
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1, size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0, angle: Number(d.marker?.angle) || 0,
tapAction: d.marker?.tap_action || '', tapAction: d.marker?.tap_action || '',
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
controls: [...(d.marker?.controls || [])], controls: [...(d.marker?.controls || [])],
controlsFilter: '', controlsFilter: '',
glowRadius: Number(d.marker?.glow_radius_cm) > 0 glowRadius: Number(d.marker?.glow_radius_cm) > 0
@@ -2172,7 +2174,7 @@ class HouseplanCard extends LitElement {
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false, name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
showEntities: false, bindingFilter: '', icon: '', autoIcon: '', showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0, display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', controls: [], controlsFilter: '', glowRadius: '', model: '', tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false, link: '', description: '', pdfs: [], room: '', busy: false,
}; };
} }
@@ -3201,8 +3203,11 @@ class HouseplanCard extends LitElement {
const label = !space.bg && !disp.showNames && !this._markup; const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(r); const c = this._roomCenter(r);
// open boundaries: this room's solid stroke must not run beneath // open boundaries: this room's solid stroke must not run beneath
// the dashed stretches — suppress it and draw a trimmed outline // the dashed stretches — suppress it and draw a trimmed outline.
const openCuts = !this._markup && r.id // Applies in the Plan editor too (picked rooms keep their full
// amber highlight — the merge/split selection must stay visible).
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
const openCuts = r.id && !isPicked
? this._openPairs() ? this._openPairs()
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id) .filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
.flatMap((pp) => pp.segs) .flatMap((pp) => pp.segs)
@@ -3218,27 +3223,29 @@ class HouseplanCard extends LitElement {
const shape = holes.length && myPoly const shape = holes.length && myPoly
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd" ? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
d="${[myPoly, ...holes].map(pathD).join(' ')}" d="${[myPoly, ...holes].map(pathD).join(' ')}"
@click=${() => this._clickRoom(r)} @mousemove=${tip} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></path>` @mouseleave=${() => (this._tip = null)}></path>`
: r.poly : r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}" ? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@click=${() => this._clickRoom(r)} @mousemove=${tip} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></polygon>` @mouseleave=${() => (this._tip = null)}></polygon>`
: svg`<rect class="${cls}" style="${style}" : svg`<rect class="${cls}" style="${style}"
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}" 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} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`; @mouseleave=${() => (this._tip = null)}></rect>`;
const trimmed = openCuts.length && myPoly const trimmed = openCuts.length && myPoly
? outlineWithout(myPoly, openCuts, this._gridPitch * 0.02) ? outlineWithout(myPoly, openCuts, this._gridPitch * 0.02)
: null; : null;
const outline = trimmed const outline = trimmed
? svg`<path class="room-outline" d="${trimmed.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`).join(' ')}" ? svg`<path class="room-outline ${this._markup ? 'outlined' : ''}"
style="stroke:${disp.color};stroke-opacity:${disp.showBorders && !this._markup ? disp.opacity : 0}"></path>` d="${trimmed.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`).join(' ')}"
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
: nothing; : nothing;
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`; return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})} })}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing} ${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderOpenWalls(disp)} ${this._renderOpenWalls(disp)}
${this._editing ? this._renderAlignGuides() : nothing}
${this._markup ? this._renderMarkupLayer(vb) : nothing} ${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)} ${this._renderOpenings(disp)}
</svg> </svg>
@@ -3503,7 +3510,12 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)} @pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)} @pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)} @pointercancel=${() => this._labelUp(r)}
><span class="rlname">${r.name}</span> ><span class="rlname">${r.name}${!this._markup && r.area
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
@pointerdown=${(e: Event) => e.stopPropagation()}></ha-icon>`
: nothing}</span>
${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing} ${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing}
${this._mode === 'plan' ${this._mode === 'plan'
? ['tl', 'tr', 'bl', 'br'].map( ? ['tl', 'tr', 'bl', 'br'].map(
@@ -3533,7 +3545,121 @@ class HouseplanCard extends LitElement {
const b = this._cursorPt!; const b = this._cursorPt!;
const left = ((b[0] - view.x) / view.w) * 100; const left = ((b[0] - view.x) / view.w) * 100;
const top = ((b[1] - view.y) / view.h) * 100; const top = ((b[1] - view.y) / view.h) * 100;
return html`<div class="measurelabel" style="left:${left}%;top:${top}%">${this._fmtLen(a, b)}</div>`; // angle badge: length · angle, both green when the angle is a 45° multiple
const deg = segmentAngle(a, b);
const shown = Math.round(deg * 10) / 10;
const on45 = is45(deg);
return html`<div class="measurelabel ${on45 ? 'on45' : ''}" style="left:${left}%;top:${top}%">
${this._fmtLen(a, b)} · ${shown}°</div>`;
}
// ================= alignment guides (smart guides) =================
/** The point being drawn/dragged right now, or null (per editor context). */
private get _alignPoint(): number[] | null {
if (this._markup) {
if (this._tool === 'draw' && this._path.length && !this._contourClosed && this._cursorPt)
return this._cursorPt;
if (this._tool === 'split' && this._splitSel?.pts?.length && this._cursorPt)
return this._cursorPt;
if (this._drag?.id.startsWith('rl_') && this._drag.moved) {
const roomId = this._drag.id.slice(3);
const room = this._spaceModel().rooms.find((r) => r.id === roomId);
return room ? (() => { const p = this._labelPos(room, this._space); return [p.x, p.y]; })() : null;
}
return null;
}
if (this._mode === 'devices' && this._drag?.moved) {
const d = this._devices.find((x) => x.id === this._drag!.id);
return d ? (() => { const p = this._pos(d); return [p.x, p.y]; })() : null;
}
if (this._mode === 'decor') {
if (this._decorDraft) return this._decorDraft.b;
if (this._decorMove) {
const sh = this._decorList.find((x) => x.id === this._decorMove!.id);
if (!sh) return null;
const W = NORM_W, H = this._decorH;
if (sh.kind === 'line') return [sh.x1 * W, sh.y1 * H];
return [sh.x * W, sh.y * H];
}
return null;
}
return null;
}
/** Alignment candidates for the current context (owner's matrix). */
private _alignCandidates(): number[][] {
const out: number[][] = [];
const spm = this._spaceModel();
if (this._markup) {
if (this._drag?.id.startsWith('rl_')) {
// room-card drag: centers of the OTHER room cards
const dragged = this._drag.id.slice(3);
for (const r of spm.rooms) {
if (!r.name || r.id === dragged) continue;
const p = this._labelPos(r, this._space);
out.push([p.x, p.y]);
}
return out;
}
// drawing: room vertices + current path/split points
for (const r of spm.rooms) {
const poly = roomPoly(r);
if (poly) for (const p of poly) out.push(p);
}
if (this._tool === 'draw') for (const p of this._path) out.push(p);
if (this._tool === 'split' && this._splitSel?.pts) for (const p of this._splitSel.pts) out.push(p);
return out;
}
if (this._mode === 'devices') {
// other icons of this space only (owner's decision)
for (const d of this._devices) {
if (d.space !== this._space || d.id === this._drag?.id) continue;
const p = this._pos(d);
out.push([p.x, p.y]);
}
return out;
}
if (this._mode === 'decor') {
const W = NORM_W, H = this._decorH;
const movingId = this._decorMove?.id;
for (const sh of this._decorList) {
if (sh.id === movingId) continue;
if (sh.kind === 'line') { out.push([sh.x1 * W, sh.y1 * H], [sh.x2 * W, sh.y2 * H]); }
else if (sh.kind === 'text') out.push([sh.x * W, sh.y * H]);
else {
out.push([sh.x * W, sh.y * H], [(sh.x + sh.w) * W, sh.y * H],
[sh.x * W, (sh.y + sh.h) * H], [(sh.x + sh.w) * W, (sh.y + sh.h) * H]);
}
}
if (this._decorDraft) out.push(this._decorDraft.a);
for (const r of spm.rooms) {
const poly = roomPoly(r);
if (poly) for (const p of poly) out.push(p);
}
return out;
}
return out;
}
private _renderAlignGuides(): TemplateResult {
const pt = this._alignPoint;
if (!pt) return svg`` as unknown as TemplateResult;
// exact node match for grid-snapped things; half a cell for free-moving cards
const tol = this._drag?.id.startsWith('rl_') ? this._gridPitch * 0.5 : this._gridPitch * 0.05;
const guides = alignGuides(pt, this._alignCandidates(), tol);
if (!guides.length) return svg`` as unknown as TemplateResult;
const g = this._gridPitch;
const over = g * 1.5; // extend a little past the point
return svg`<g class="alignguides">
${guides.map((gd: AlignGuide) => {
const [x1, y1, x2, y2] = gd.axis === 'x'
? [gd.at, gd.from[1], gd.at, pt[1] + Math.sign(pt[1] - gd.from[1]) * over]
: [gd.from[0], gd.at, pt[0] + Math.sign(pt[0] - gd.from[0]) * over, gd.at];
return svg`<line class="alignline" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"></line>
<circle class="aligndot" cx="${gd.from[0]}" cy="${gd.from[1]}" r="${g * 0.18}"></circle>`;
})}
</g>` as unknown as TemplateResult;
} }
private _roomCenter(r: RoomCfg): number[] { private _roomCenter(r: RoomCfg): number[] {
@@ -3776,7 +3902,11 @@ class HouseplanCard extends LitElement {
} }
private _renderMarkupLayer(vb: number[]): TemplateResult { private _renderMarkupLayer(vb: number[]): TemplateResult {
const segs = this._segments; // derived walls minus the open stretches — those are drawn dashed on top
const openCuts = this._openPairs().flatMap((p) => p.segs);
const segs = openCuts.length
? cutSegments(this._segments, openCuts, this._gridPitch * 0.02)
: this._segments;
const path = this._path; const path = this._path;
const g = this._gridPitch; const g = this._gridPitch;
return svg` return svg`
@@ -4020,7 +4150,7 @@ class HouseplanCard extends LitElement {
<select class="areasel" <select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}> @change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map( ${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || 'info') === v}>${this._t(k as any)}</option>`, ([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
)} )}
</select> </select>
+2 -1
View File
@@ -300,5 +300,6 @@
"marker.from_ha_option": "Pick from the HA list", "marker.from_ha_option": "Pick from the HA list",
"marker.show_entities": "Show entities", "marker.show_entities": "Show entities",
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too", "marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…" "marker.pick_ph": "Choose a device…",
"room.open_area": "Open the HA area"
} }
+2 -1
View File
@@ -300,5 +300,6 @@
"marker.from_ha_option": "Выбрать из списка HA", "marker.from_ha_option": "Выбрать из списка HA",
"marker.show_entities": "Отображать сущности", "marker.show_entities": "Отображать сущности",
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности", "marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…" "marker.pick_ph": "Выберите устройство…",
"room.open_area": "Открыть зону в HA"
} }
+69 -7
View File
@@ -492,7 +492,11 @@ export function resolveTapAction(
cardDefault: string | null | undefined, cardDefault: string | null | undefined,
domain: string | null | undefined, domain: string | null | undefined,
): TapAction { ): TapAction {
const want = explicit || cardDefault || 'info'; // Pure light sources (the device's PRIMARY function is a lamp: bulbs,
// chandeliers, night lights, light groups) toggle by default — no explicit
// setting needed. Devices where light is a side function (a kettle's
// backlight) have a non-light primary and keep the info default.
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
if (want === 'more-info') return 'more-info'; if (want === 'more-info') return 'more-info';
if (want !== 'toggle') return 'info'; if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info'; if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
@@ -912,14 +916,13 @@ export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: strin
} }
/** /**
* Room outline pieces with the given collinear stretches removed used to * Segments with the given collinear stretches removed the workhorse behind
* draw a TRUE dashed open boundary (the solid stroke must not run beneath). * TRUE dashed open boundaries (derived walls and room outlines alike).
* Returns segments [x1,y1,x2,y2].
*/ */
export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6): number[][] { export function cutSegments(segs: number[][], cuts: number[][], eps = 1e-6): number[][] {
const out: number[][] = []; const out: number[][] = [];
for (let i = 0; i < poly.length; i++) { for (const seg of segs) {
const p1 = poly[i], p2 = poly[(i + 1) % poly.length]; const p1 = [seg[0], seg[1]], p2 = [seg[2], seg[3]];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1]; const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy); const len = Math.hypot(dx, dy);
if (len < eps) continue; if (len < eps) continue;
@@ -952,6 +955,65 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
return out; return out;
} }
/** Room outline pieces with the given collinear stretches removed. */
export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6): number[][] {
const edges: number[][] = [];
for (let i = 0; i < poly.length; i++) {
const p1 = poly[i], p2 = poly[(i + 1) % poly.length];
edges.push([p1[0], p1[1], p2[0], p2[1]]);
}
return cutSegments(edges, cuts, eps);
}
// ---------------- alignment guides ----------------
export interface AlignGuide {
axis: 'x' | 'y';
/** The candidate's aligned coordinate (x for axis x, y for axis y). */
at: number;
/** The candidate point the guide is drawn from. */
from: number[];
}
/**
* Alignment guides for a point being drawn/dragged: the nearest candidate
* sharing its X and the nearest sharing its Y (within tol). Indication only
* no magnetism, the grid owns the actual position (owner's decision).
*/
export function alignGuides(pt: number[], candidates: number[][], tol: number): AlignGuide[] {
let bestX: { d: number; c: number[] } | null = null;
let bestY: { d: number; c: number[] } | null = null;
for (const c of candidates) {
const same = Math.abs(c[0] - pt[0]) < 1e-6 && Math.abs(c[1] - pt[1]) < 1e-6;
if (same) continue;
if (Math.abs(c[0] - pt[0]) <= tol) {
const d = Math.abs(c[1] - pt[1]);
if (d > 1e-6 && (!bestX || d < bestX.d)) bestX = { d, c };
}
if (Math.abs(c[1] - pt[1]) <= tol) {
const d = Math.abs(c[0] - pt[0]);
if (d > 1e-6 && (!bestY || d < bestY.d)) bestY = { d, c };
}
}
const out: AlignGuide[] = [];
if (bestX) out.push({ axis: 'x', at: bestX.c[0], from: bestX.c });
if (bestY) out.push({ axis: 'y', at: bestY.c[1], from: bestY.c });
return out;
}
/** Segment angle in degrees, normalized to [0, 360). */
export function segmentAngle(a: number[], b: number[]): number {
let deg = (Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI;
if (deg < 0) deg += 360;
return deg;
}
/** Is the angle a multiple of 45° (within tolerance)? */
export function is45(deg: number, tol = 0.5): boolean {
const m = ((deg % 45) + 45) % 45;
return m <= tol || 45 - m <= tol;
}
/** Distance from a point to a segment [x1,y1,x2,y2]. */ /** Distance from a point to a segment [x1,y1,x2,y2]. */
export function distToSegment(p: number[], s: number[]): number { export function distToSegment(p: number[], s: number[]): number {
const dx = s[2] - s[0], dy = s[3] - s[1]; const dx = s[2] - s[0], dy = s[3] - s[1];
+32 -1
View File
@@ -189,7 +189,7 @@ export const cardStyles = css`
} }
.room { .room {
transition: 0.12s; transition: 0.12s;
cursor: pointer; cursor: default; /* v1.40.1: rooms are not clickable — the label's link icon is */
} }
.room.overlay { .room.overlay {
fill: transparent; fill: transparent;
@@ -377,6 +377,17 @@ export const cardStyles = css`
gap: 0.15em; gap: 0.15em;
text-align: center; text-align: center;
} }
.rlname { display: inline-flex; align-items: center; gap: 0.25em; }
.rlgo {
--mdc-icon-size: 0.85em;
display: inline-flex;
opacity: 0.55;
}
.stage.mode-view .rlgo {
pointer-events: auto;
cursor: pointer;
}
.stage.mode-view .rlgo:hover { opacity: 1; }
.roomlabel .rlmetrics { .roomlabel .rlmetrics {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -479,6 +490,21 @@ export const cardStyles = css`
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
} }
.alignline {
stroke: var(--hp-accent);
stroke-width: 1.2;
stroke-dasharray: 4 4;
pointer-events: none;
opacity: 0.9;
}
.aligndot {
fill: var(--hp-accent);
pointer-events: none;
}
.measurelabel.on45 {
color: #4bd28f;
border-color: #4bd28f;
}
.measurelabel { .measurelabel {
position: absolute; position: absolute;
transform: translate(12px, -150%); transform: translate(12px, -150%);
@@ -595,6 +621,11 @@ export const cardStyles = css`
stroke-width: 2.5; stroke-width: 2.5;
pointer-events: none; pointer-events: none;
} }
/* Plan editor: trimmed outlines use the markup blue */
.room-outline.outlined {
stroke: rgba(62, 166, 255, 0.55);
stroke-opacity: 1;
}
.openwalls.hot .openwall { .openwalls.hot .openwall {
stroke: #ffc14d; stroke: #ffc14d;
opacity: 1; opacity: 1;
+44 -2
View File
@@ -8,6 +8,7 @@ import {
controlsAction, isControllable, controlsAction, isControllable,
sharedBoundary, openZoneOf, distToSegment, sharedBoundary, openZoneOf, distToSegment,
outlineWithout, outlineWithout,
alignGuides, segmentAngle, is45,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
@@ -175,8 +176,9 @@ test('icon rules: device_class fallback', () => {
assert.equal(iconFromDeviceClasses([]), null); assert.equal(iconFromDeviceClasses([]), null);
}); });
test('tap action: defaults to info', () => { test('tap action: defaults info everywhere except pure lights (v1.39.0)', () => {
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info'); assert.equal(resolveTapAction(undefined, undefined, 'light'), 'toggle'); // лампы кликабельны из коробки
assert.equal(resolveTapAction(undefined, undefined, 'switch'), 'info');
assert.equal(resolveTapAction(null, 'info', 'switch'), 'info'); assert.equal(resolveTapAction(null, 'info', 'switch'), 'info');
assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info'); assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info');
}); });
@@ -721,3 +723,43 @@ test('outlineWithout: removes the cut stretch, keeps the rest', () => {
// без вырезов — весь периметр // без вырезов — весь периметр
assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6); assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6);
}); });
test('resolveTapAction: pure lights toggle by default (v1.39.0)', () => {
assert.equal(resolveTapAction(null, null, 'light'), 'toggle');
assert.equal(resolveTapAction('', undefined, 'light'), 'toggle');
// явный выбор пользователя сильнее дефолта
assert.equal(resolveTapAction('info', null, 'light'), 'info');
// не-световые домены не трогаем
assert.equal(resolveTapAction(null, null, 'switch'), 'info');
assert.equal(resolveTapAction(null, null, 'sensor'), 'info');
// замок никогда
assert.equal(resolveTapAction('toggle', null, 'lock'), 'info');
});
test('alignGuides: nearest per axis, indication only', () => {
const cands = [[10, 50], [10, 90], [70, 20], [40, 40]];
const g = alignGuides([10, 60], cands, 0.5);
const gx = g.find((x) => x.axis === 'x');
assert.ok(gx && gx.at === 10 && gx.from[1] === 50); // ближайший по Y из выровненных по X
// выравнивание по Y
const g2 = alignGuides([30, 20], cands, 0.5);
const gy = g2.find((x) => x.axis === 'y');
assert.ok(gy && gy.at === 20 && gy.from[0] === 70);
// вне допуска — пусто
assert.equal(alignGuides([11, 60], cands, 0.5).length, 0);
// совпадающая точка не гид сама себе
assert.equal(alignGuides([10, 50], [[10, 50]], 0.5).length, 0);
// максимум два гида
const g3 = alignGuides([10, 20], cands, 0.5);
assert.ok(g3.length <= 2);
});
test('segmentAngle & is45', () => {
assert.equal(segmentAngle([0, 0], [10, 0]), 0);
assert.equal(segmentAngle([0, 0], [0, 10]), 90);
assert.equal(segmentAngle([0, 0], [10, 10]), 45);
assert.equal(segmentAngle([0, 0], [-10, 0]), 180);
assert.ok(is45(45) && is45(90) && is45(315) && is45(0));
assert.ok(is45(44.8) && is45(45.3));
assert.ok(!is45(30) && !is45(52));
});