mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Merge dev: v1.38.3..v1.40.1 (dashed walls in editor, default light toggle, smart guides, room link)
This commit is contained in:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.38.2"
|
||||
VERSION = "1.40.1"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.38.2"
|
||||
"version": "1.40.1"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -24,7 +24,20 @@ const res = await page.evaluate(async () => {
|
||||
const gi = order.indexOf('glowlayer');
|
||||
const oi = order.indexOf('openwalls');
|
||||
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;
|
||||
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
|
||||
|
||||
@@ -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
Vendored
+72
-32
File diff suppressed because one or more lines are too long
@@ -1,5 +1,43 @@
|
||||
# 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
|
||||
- The card now **remembers where you were**: the selected space and the active
|
||||
editor survive navigation and closing the tab (localStorage; edit modes are
|
||||
|
||||
@@ -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]
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
space AND editor mode (admins; localStorage); a #space= deep link beats
|
||||
the saved space; a stale cache without the saved space retries after the
|
||||
|
||||
+1
-1
@@ -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 →
|
||||
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),
|
||||
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):
|
||||
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.38.2",
|
||||
"version": "1.40.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+145
-15
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
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,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -169,7 +169,8 @@ class HouseplanCard extends LitElement {
|
||||
rippleSize: number; // in icon diameters
|
||||
size: number; // icon size multiplier
|
||||
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
|
||||
controlsFilter: string;
|
||||
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,
|
||||
angle: Number(d.marker?.angle) || 0,
|
||||
tapAction: d.marker?.tap_action || '',
|
||||
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
|
||||
controls: [...(d.marker?.controls || [])],
|
||||
controlsFilter: '',
|
||||
glowRadius: Number(d.marker?.glow_radius_cm) > 0
|
||||
@@ -2172,7 +2174,7 @@ class HouseplanCard extends LitElement {
|
||||
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
|
||||
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -3201,8 +3203,11 @@ class HouseplanCard extends LitElement {
|
||||
const label = !space.bg && !disp.showNames && !this._markup;
|
||||
const c = this._roomCenter(r);
|
||||
// open boundaries: this room's solid stroke must not run beneath
|
||||
// the dashed stretches — suppress it and draw a trimmed outline
|
||||
const openCuts = !this._markup && r.id
|
||||
// the dashed stretches — suppress it and draw a trimmed outline.
|
||||
// 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()
|
||||
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
|
||||
.flatMap((pp) => pp.segs)
|
||||
@@ -3218,27 +3223,29 @@ class HouseplanCard extends LitElement {
|
||||
const shape = holes.length && myPoly
|
||||
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
|
||||
d="${[myPoly, ...holes].map(pathD).join(' ')}"
|
||||
@click=${() => this._clickRoom(r)} @mousemove=${tip}
|
||||
@mousemove=${tip}
|
||||
@mouseleave=${() => (this._tip = null)}></path>`
|
||||
: r.poly
|
||||
? 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>`
|
||||
: 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}"
|
||||
@click=${() => this._clickRoom(r)} @mousemove=${tip}
|
||||
@mousemove=${tip}
|
||||
@mouseleave=${() => (this._tip = null)}></rect>`;
|
||||
const trimmed = openCuts.length && myPoly
|
||||
? outlineWithout(myPoly, openCuts, this._gridPitch * 0.02)
|
||||
: null;
|
||||
const outline = trimmed
|
||||
? svg`<path class="room-outline" d="${trimmed.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`).join(' ')}"
|
||||
style="stroke:${disp.color};stroke-opacity:${disp.showBorders && !this._markup ? disp.opacity : 0}"></path>`
|
||||
? svg`<path class="room-outline ${this._markup ? 'outlined' : ''}"
|
||||
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;
|
||||
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}
|
||||
${this._renderOpenWalls(disp)}
|
||||
${this._editing ? this._renderAlignGuides() : nothing}
|
||||
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||
${this._renderOpenings(disp)}
|
||||
</svg>
|
||||
@@ -3503,7 +3510,12 @@ class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
|
||||
@pointerup=${() => 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}
|
||||
${this._mode === 'plan'
|
||||
? ['tl', 'tr', 'bl', 'br'].map(
|
||||
@@ -3533,7 +3545,121 @@ class HouseplanCard extends LitElement {
|
||||
const b = this._cursorPt!;
|
||||
const left = ((b[0] - view.x) / view.w) * 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[] {
|
||||
@@ -3776,7 +3902,11 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
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 g = this._gridPitch;
|
||||
return svg`
|
||||
@@ -4020,7 +4150,7 @@ class HouseplanCard extends LitElement {
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
|
||||
${[['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>
|
||||
|
||||
|
||||
+2
-1
@@ -300,5 +300,6 @@
|
||||
"marker.from_ha_option": "Pick from the HA list",
|
||||
"marker.show_entities": "Show entities",
|
||||
"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
@@ -300,5 +300,6 @@
|
||||
"marker.from_ha_option": "Выбрать из списка HA",
|
||||
"marker.show_entities": "Отображать сущности",
|
||||
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
|
||||
"marker.pick_ph": "Выберите устройство…"
|
||||
"marker.pick_ph": "Выберите устройство…",
|
||||
"room.open_area": "Открыть зону в HA"
|
||||
}
|
||||
|
||||
+69
-7
@@ -492,7 +492,11 @@ export function resolveTapAction(
|
||||
cardDefault: string | null | undefined,
|
||||
domain: string | null | undefined,
|
||||
): 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 !== 'toggle') 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
|
||||
* draw a TRUE dashed open boundary (the solid stroke must not run beneath).
|
||||
* Returns segments [x1,y1,x2,y2].
|
||||
* Segments with the given collinear stretches removed — the workhorse behind
|
||||
* TRUE dashed open boundaries (derived walls and room outlines alike).
|
||||
*/
|
||||
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[][] = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p1 = poly[i], p2 = poly[(i + 1) % poly.length];
|
||||
for (const seg of segs) {
|
||||
const p1 = [seg[0], seg[1]], p2 = [seg[2], seg[3]];
|
||||
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < eps) continue;
|
||||
@@ -952,6 +955,65 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
||||
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]. */
|
||||
export function distToSegment(p: number[], s: number[]): number {
|
||||
const dx = s[2] - s[0], dy = s[3] - s[1];
|
||||
|
||||
+32
-1
@@ -189,7 +189,7 @@ export const cardStyles = css`
|
||||
}
|
||||
.room {
|
||||
transition: 0.12s;
|
||||
cursor: pointer;
|
||||
cursor: default; /* v1.40.1: rooms are not clickable — the label's link icon is */
|
||||
}
|
||||
.room.overlay {
|
||||
fill: transparent;
|
||||
@@ -377,6 +377,17 @@ export const cardStyles = css`
|
||||
gap: 0.15em;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -479,6 +490,21 @@ export const cardStyles = css`
|
||||
inset: 0;
|
||||
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 {
|
||||
position: absolute;
|
||||
transform: translate(12px, -150%);
|
||||
@@ -595,6 +621,11 @@ export const cardStyles = css`
|
||||
stroke-width: 2.5;
|
||||
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 {
|
||||
stroke: #ffc14d;
|
||||
opacity: 1;
|
||||
|
||||
+44
-2
@@ -8,6 +8,7 @@ import {
|
||||
controlsAction, isControllable,
|
||||
sharedBoundary, openZoneOf, distToSegment,
|
||||
outlineWithout,
|
||||
alignGuides, segmentAngle, is45,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
@@ -175,8 +176,9 @@ test('icon rules: device_class fallback', () => {
|
||||
assert.equal(iconFromDeviceClasses([]), null);
|
||||
});
|
||||
|
||||
test('tap action: defaults to info', () => {
|
||||
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info');
|
||||
test('tap action: defaults — info everywhere except pure lights (v1.39.0)', () => {
|
||||
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, '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);
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user