ux v1.37.1: open-wall tool hover — default cursor, pointer + stretch preview near walls

- _openWallHit shared by click and hover; amber dashed preview of the
  stretch that would open, red solid when the click would close it;
  stage.wallhot drives the pointer cursor
- smoke_openwall_hover.mjs (9 checks); TESTING/CHANGELOG same-commit
This commit is contained in:
Matysh
2026-07-23 14:24:55 +03:00
parent 20c7b55a87
commit 6b9909768f
11 changed files with 437 additions and 303 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.37.0" VERSION = "1.37.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.37.0" "version": "1.37.1"
} }
+36
View File
@@ -0,0 +1,36 @@
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 stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
const H = 1000 / (c._curSpaceCfg.aspect || 1);
// 1) без наведения: курсор default, превью нет
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
out.noPreview = !sr().querySelector('.openwall-preview');
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
const prev = sr().querySelectorAll('.openwall-preview');
out.previewShown = prev.length > 0;
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
// превью лежит на стене x=550
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
// 3) наведение в центр комнаты: снова default, превью исчезло
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
out.missCursor = getComputedStyle(stage()).cursor === 'default';
out.missNoPreview = !sr().querySelector('.openwall-preview');
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
const prev2 = sr().querySelector('.openwall-preview');
out.willclose = prev2?.classList.contains('willclose') === true;
// прибрать
c._openWallClick([550, 0.25 * H]); 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
+113 -96
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## v1.37.1 — 2026-07-23
- Open-boundary tool polish: the cursor stays default and only turns into a
pointer near a wall shared by two rooms; hovering previews the exact
stretch that would become open (amber dashed) — or red solid when the
boundary is already open and the click would close it.
## v1.37.0 — 2026-07-23 (open boundaries — virtual walls) ## v1.37.0 — 2026-07-23 (open boundaries — virtual walls)
- Rooms divided only by zoning can now share an **open boundary**: the new - Rooms divided only by zoning can now share an **open boundary**: the new
"Open boundary" tool in the Plan editor toggles it with a click on the wall "Open boundary" tool in the Plan editor toggles it with a click on the wall
+5
View File
@@ -140,6 +140,11 @@ 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
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
near a shared wall it turns pointer and the exact stretch that would
open is previewed (amber dashed); an already-open boundary previews red
solid (the click will close it); preview follows the cursor and clears
on miss [auto]
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool - [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
toggles a virtual wall between two rooms by clicking their shared wall toggles a virtual wall between two rooms by clicking their shared wall
(pull like Split; miss → toast); open stretches render dashed (amber (pull like Split; miss → toast); open stretches render dashed (amber
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.37.0", "version": "1.37.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",
+32 -11
View File
@@ -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.37.0'; const CARD_VERSION = '1.37.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';
@@ -1598,14 +1598,26 @@ class HouseplanCard extends LitElement {
</div>`; </div>`;
} }
/** Boundary under the cursor in the open-wall tool (hover preview). */
private get _openWallHover(): { segs: number[][]; open: boolean } | null {
if (!this._markup || this._tool !== 'openwall' || !this._cursorPt) return null;
const hit = this._openWallHit(this._cursorPt);
return hit ? { segs: hit.segs, open: hit.open } : null;
}
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */ /** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
private _renderOpenWalls(): TemplateResult { private _renderOpenWalls(): TemplateResult {
const pairs = this._openPairs(); const pairs = this._openPairs();
if (!pairs.length) return svg`` as unknown as TemplateResult; const hover = this._openWallHover;
if (!pairs.length && !hover) return svg`` as unknown as TemplateResult;
const hot = this._markup && this._tool === 'openwall'; const hot = this._markup && this._tool === 'openwall';
return svg`<g class="openwalls ${hot ? 'hot' : ''}"> return svg`<g class="openwalls ${hot ? 'hot' : ''}">
${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall" ${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))} x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))}
${hover
? hover.segs.map((sg) => svg`<line class="openwall-preview ${hover.open ? 'willclose' : ''}"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`)
: nothing}
</g>` as unknown as TemplateResult; </g>` as unknown as TemplateResult;
} }
@@ -1626,11 +1638,11 @@ class HouseplanCard extends LitElement {
return res; return res;
} }
/** Open-boundary tool: a click on a shared wall toggles its "virtual" state. */ /** The shared boundary nearest to the cursor (both the tool's click and hover). */
private _openWallClick(raw: number[]): void { private _openWallHit(raw: number[]): { a: RoomCfg; b: RoomCfg; segs: number[][]; open: boolean } | null {
const rooms = this._spaceModel().rooms.filter((r) => r.id); const rooms = this._spaceModel().rooms.filter((r) => r.id);
const pull = this._gridPitch * 6; const pull = this._gridPitch * 6;
let best: { a: RoomCfg; b: RoomCfg; d: number } | null = null; let best: { a: RoomCfg; b: RoomCfg; segs: number[][]; d: number } | null = null;
for (let i = 0; i < rooms.length; i++) for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) { for (let j = i + 1; j < rooms.length; j++) {
const pa = roomPoly(rooms[i]), pb = roomPoly(rooms[j]); const pa = roomPoly(rooms[i]), pb = roomPoly(rooms[j]);
@@ -1638,16 +1650,25 @@ class HouseplanCard extends LitElement {
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02); const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
for (const seg of segs) { for (const seg of segs) {
const d = distToSegment(raw, seg); const d = distToSegment(raw, seg);
if (d <= pull && (!best || d < best.d)) best = { a: rooms[i], b: rooms[j], d }; if (d <= pull && (!best || d < best.d)) best = { a: rooms[i], b: rooms[j], segs, d };
} }
} }
if (!best) return null;
const open = (((best.a as any).open_to || []).includes(best.b.id))
|| (((best.b as any).open_to || []).includes(best.a.id));
return { a: best.a, b: best.b, segs: best.segs, open };
}
/** Open-boundary tool: a click on a shared wall toggles its "virtual" state. */
private _openWallClick(raw: number[]): void {
const best = this._openWallHit(raw);
if (!best) { if (!best) {
this._showToast(this._t('toast.openwall_pick')); this._showToast(this._t('toast.openwall_pick'));
return; return;
} }
const sp = this._curSpaceCfg; const sp = this._curSpaceCfg;
const ra = sp.rooms.find((r: any) => r.id === best!.a.id); const ra = sp.rooms.find((r: any) => r.id === best.a.id);
const rb = sp.rooms.find((r: any) => r.id === best!.b.id); const rb = sp.rooms.find((r: any) => r.id === best.b.id);
if (!ra || !rb) return; if (!ra || !rb) return;
const linked = (ra.open_to || []).includes(rb.id) || (rb.open_to || []).includes(ra.id); const linked = (ra.open_to || []).includes(rb.id) || (rb.open_to || []).includes(ra.id);
if (linked) { if (linked) {
@@ -1925,8 +1946,8 @@ class HouseplanCard extends LitElement {
private _markupMove(ev: MouseEvent): void { private _markupMove(ev: MouseEvent): void {
if (!this._markup) return; if (!this._markup) return;
if (this._tool === 'opening') { if (this._tool === 'opening' || this._tool === 'openwall') {
// hover preview: where the opening would land (raw point, wall-snapped later) // hover preview: raw cursor point; snapping happens in the preview getters
this._cursorPt = this._svgPoint(ev); this._cursorPt = this._svgPoint(ev);
return; return;
} }
@@ -3077,7 +3098,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing} ${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div> </div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}" <div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:calc(100dvh - 118px)" style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)} @click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)}
+16 -1
View File
@@ -538,10 +538,12 @@ export const cardStyles = css`
/* room-picking stages: merge (both clicks) and split before a room is chosen */ /* room-picking stages: merge (both clicks) and split before a room is chosen */
.stage.markup.tool-merge, .stage.markup.tool-merge,
.stage.markup.tool-split.pickstage, .stage.markup.tool-split.pickstage,
.stage.markup.tool-openwall,
.stage.markup.tool-delroom { .stage.markup.tool-delroom {
cursor: pointer; cursor: pointer;
} }
/* open-wall tool: default until a shared wall is under the cursor */
.stage.markup.tool-openwall { cursor: default; }
.stage.markup.tool-openwall.wallhot { cursor: pointer; }
.openwall { .openwall {
stroke: var(--card-background-color, var(--hp-bg, #fff)); stroke: var(--card-background-color, var(--hp-bg, #fff));
stroke-width: 3.2; stroke-width: 3.2;
@@ -553,6 +555,19 @@ export const cardStyles = css`
stroke: #ffc14d; stroke: #ffc14d;
opacity: 1; opacity: 1;
} }
.openwall-preview {
stroke: #ffc14d;
stroke-width: 5;
stroke-dasharray: 7 7;
stroke-linecap: round;
pointer-events: none;
opacity: 0.95;
}
/* an already-open boundary under the cursor: the click will CLOSE it */
.openwall-preview.willclose {
stroke: #f25a4a;
stroke-dasharray: none;
}
.stage.markup .room { .stage.markup .room {
pointer-events: none; pointer-events: none;
} }