mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Vendored
+113
-96
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user