mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix v1.37.3: true dashed open boundary, rendered above the glow
- outlineWithout trims the rooms' solid strokes under open stretches (.room.noedge kills the polygon stroke incl. hover; trimmed .room-outline path draws the remaining walls) - dash color follows the space stroke color; openwalls layer moved after the glow layer - +1 unit test (105); smoke_openwall extended; docs 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.2"
|
VERSION = "1.37.3"
|
||||||
|
|
||||||
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.2"
|
"version": "1.37.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ const res = await page.evaluate(async () => {
|
|||||||
// пунктир отрисован
|
// пунктир отрисован
|
||||||
out.dashes = sr().querySelectorAll('.openwall').length > 0;
|
out.dashes = sr().querySelectorAll('.openwall').length > 0;
|
||||||
out.hotClass = !!sr().querySelector('.openwalls.hot');
|
out.hotClass = !!sr().querySelector('.openwalls.hot');
|
||||||
|
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
|
||||||
|
c._setMode('view'); await c.updateComplete;
|
||||||
|
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
|
||||||
|
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
|
||||||
|
const svgEl = sr().querySelector('svg');
|
||||||
|
const order = [...svgEl.children].map((el) => el.classList?.[0] || el.tagName);
|
||||||
|
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._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');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+26
-14
File diff suppressed because one or more lines are too long
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.37.3 — 2026-07-23
|
||||||
|
- Open boundaries now render as a **true dash**: the rooms' solid outlines are
|
||||||
|
trimmed under the open stretch (outlineWithout) instead of dashes being
|
||||||
|
painted over a solid line, and the dashed layer moved **above the glow
|
||||||
|
pools** so light never covers it.
|
||||||
|
|
||||||
## v1.37.2 — 2026-07-23
|
## v1.37.2 — 2026-07-23
|
||||||
- Glow falloff tuned: full brightness for the inner 70% of the radius,
|
- Glow falloff tuned: full brightness for the inner 70% of the radius,
|
||||||
gradient on the outer 30% (was 80/20).
|
gradient on the outer 30% (was 80/20).
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ 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
|
||||||
|
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
|
||||||
|
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
|
||||||
|
them back), walls elsewhere stay solid; the dashes render ABOVE the
|
||||||
|
glow pools [auto]
|
||||||
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
|
- [ ] 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
|
near a shared wall it turns pointer and the exact stretch that would
|
||||||
open is previewed (amber dashed); an already-open boundary previews red
|
open is previewed (amber dashed); an already-open boundary previews red
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.37.2",
|
"version": "1.37.3",
|
||||||
"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",
|
||||||
|
|||||||
+22
-6
@@ -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,
|
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout,
|
||||||
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.37.2';
|
const CARD_VERSION = '1.37.3';
|
||||||
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';
|
||||||
@@ -1606,12 +1606,13 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 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(disp?: SpaceDisplay): TemplateResult {
|
||||||
const pairs = this._openPairs();
|
const pairs = this._openPairs();
|
||||||
const hover = this._openWallHover;
|
const hover = this._openWallHover;
|
||||||
if (!pairs.length && !hover) return svg`` as unknown as TemplateResult;
|
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' : ''}">
|
const stroke = disp?.color || 'var(--hp-muted)';
|
||||||
|
return svg`<g class="openwalls ${hot ? 'hot' : ''}" style="--ow-stroke:${stroke}">
|
||||||
${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
|
||||||
@@ -3152,6 +3153,14 @@ class HouseplanCard extends LitElement {
|
|||||||
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
|
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
|
||||||
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
|
||||||
|
// the dashed stretches — suppress it and draw a trimmed outline
|
||||||
|
const openCuts = !this._markup && r.id
|
||||||
|
? this._openPairs()
|
||||||
|
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
|
||||||
|
.flatMap((pp) => pp.segs)
|
||||||
|
: [];
|
||||||
|
if (openCuts.length) cls += ' noedge';
|
||||||
// island rooms punch holes in their parent's fill (evenodd)
|
// island rooms punch holes in their parent's fill (evenodd)
|
||||||
const myPoly = roomPoly(r);
|
const myPoly = roomPoly(r);
|
||||||
const holes = myPoly
|
const holes = myPoly
|
||||||
@@ -3172,10 +3181,17 @@ class HouseplanCard extends LitElement {
|
|||||||
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}
|
@click=${() => this._clickRoom(r)} @mousemove=${tip}
|
||||||
@mouseleave=${() => (this._tip = null)}></rect>`;
|
@mouseleave=${() => (this._tip = null)}></rect>`;
|
||||||
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
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>`
|
||||||
|
: nothing;
|
||||||
|
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||||
})}
|
})}
|
||||||
${this._renderOpenWalls()}
|
|
||||||
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||||
|
${this._renderOpenWalls(disp)}
|
||||||
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||||
${this._renderOpenings(disp)}
|
${this._renderOpenings(disp)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -911,6 +911,47 @@ export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: strin
|
|||||||
return zone;
|
return zone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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].
|
||||||
|
*/
|
||||||
|
export function outlineWithout(poly: 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];
|
||||||
|
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < eps) continue;
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
// collect cut intervals on this edge
|
||||||
|
const iv: [number, number][] = [];
|
||||||
|
for (const c of cuts) {
|
||||||
|
const d1 = Math.abs((c[0] - p1[0]) * uy - (c[1] - p1[1]) * ux);
|
||||||
|
const d2 = Math.abs((c[2] - p1[0]) * uy - (c[3] - p1[1]) * ux);
|
||||||
|
const tol = Math.max(eps, len * 1e-6);
|
||||||
|
if (d1 > tol || d2 > tol) continue;
|
||||||
|
const t1 = (c[0] - p1[0]) * ux + (c[1] - p1[1]) * uy;
|
||||||
|
const t2 = (c[2] - p1[0]) * ux + (c[3] - p1[1]) * uy;
|
||||||
|
const lo = Math.max(0, Math.min(t1, t2));
|
||||||
|
const hi = Math.min(len, Math.max(t1, t2));
|
||||||
|
if (hi - lo > eps) iv.push([lo, hi]);
|
||||||
|
}
|
||||||
|
if (!iv.length) {
|
||||||
|
out.push([p1[0], p1[1], p2[0], p2[1]]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
iv.sort((a, b) => a[0] - b[0]);
|
||||||
|
let cur = 0;
|
||||||
|
for (const [lo, hi] of iv) {
|
||||||
|
if (lo - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p1[0] + ux * lo, p1[1] + uy * lo]);
|
||||||
|
cur = Math.max(cur, hi);
|
||||||
|
}
|
||||||
|
if (len - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p2[0], p2[1]]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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];
|
||||||
|
|||||||
+13
-2
@@ -545,12 +545,23 @@ export const cardStyles = css`
|
|||||||
.stage.markup.tool-openwall { cursor: default; }
|
.stage.markup.tool-openwall { cursor: default; }
|
||||||
.stage.markup.tool-openwall.wallhot { cursor: pointer; }
|
.stage.markup.tool-openwall.wallhot { cursor: pointer; }
|
||||||
.openwall {
|
.openwall {
|
||||||
stroke: var(--card-background-color, var(--hp-bg, #fff));
|
stroke: var(--ow-stroke, var(--hp-muted));
|
||||||
stroke-width: 3.2;
|
stroke-width: 2.5;
|
||||||
stroke-dasharray: 7 7;
|
stroke-dasharray: 7 7;
|
||||||
|
stroke-linecap: butt;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
/* rooms with open stretches: the polygon's own stroke is fully off
|
||||||
|
(hover included) — the trimmed .room-outline path draws the walls */
|
||||||
|
.room.noedge {
|
||||||
|
stroke-opacity: 0 !important;
|
||||||
|
}
|
||||||
|
.room-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.openwalls.hot .openwall {
|
.openwalls.hot .openwall {
|
||||||
stroke: #ffc14d;
|
stroke: #ffc14d;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
|
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
|
||||||
controlsAction, isControllable,
|
controlsAction, isControllable,
|
||||||
sharedBoundary, openZoneOf, distToSegment,
|
sharedBoundary, openZoneOf, distToSegment,
|
||||||
|
outlineWithout,
|
||||||
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';
|
||||||
@@ -703,3 +704,20 @@ test('distToSegment', () => {
|
|||||||
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
|
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
|
||||||
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
|
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('outlineWithout: removes the cut stretch, keeps the rest', () => {
|
||||||
|
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||||
|
// вырез середины нижней стены: 3..7
|
||||||
|
const pieces = outlineWithout(sq, [[3, 0, 7, 0]]);
|
||||||
|
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
|
||||||
|
const total = pieces.reduce((a, s) => a + len(s), 0);
|
||||||
|
assert.ok(Math.abs(total - (40 - 4)) < 1e-6);
|
||||||
|
// куски нижней стены: 0..3 и 7..10
|
||||||
|
const bottom = pieces.filter((s) => s[1] === 0 && s[3] === 0);
|
||||||
|
assert.equal(bottom.length, 2);
|
||||||
|
// вырез целого ребра
|
||||||
|
const p2 = outlineWithout(sq, [[0, 0, 10, 0]]);
|
||||||
|
assert.ok(Math.abs(p2.reduce((a, s) => a + len(s), 0) - 30) < 1e-6);
|
||||||
|
// без вырезов — весь периметр
|
||||||
|
assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user