feat v1.40.0: smart guides — alignment helper in every editor

- alignGuides/segmentAngle/is45 pure helpers (+2 unit tests, 108);
  per-context candidates (room vertices + path pts / other icons /
  decor endpoints+corners / other room cards), nearest per axis, max
  two guides, dashed accent lines with a source dot
- cursor badge shows length + angle, green on 45deg multiples
- indication only, no magnetism (owner's decision); guides live in
  plan/devices/decor, never in View
- smoke_align_guides.mjs (9 checks); TESTING/CHANGELOG same-commit
This commit is contained in:
Matysh
2026-07-23 17:38:33 +03:00
parent 0522413c48
commit df9e158efb
13 changed files with 372 additions and 30 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.39.0" VERSION = "1.40.0"
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.39.0" "version": "1.40.0"
} }
+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();
File diff suppressed because one or more lines are too long
+28 -8
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## 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) ## v1.39.0 — 2026-07-23 (lights toggle by default)
- Pure light sources — devices whose primary entity is a `light` (bulbs, - Pure light sources — devices whose primary entity is a `light` (bulbs,
chandeliers, night lights, light groups) — now **toggle on click by chandeliers, night lights, light groups) — now **toggle on click by
+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
- [ ] 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 - [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
light (bulbs, chandeliers, night lights, light groups) toggles on click light (bulbs, chandeliers, night lights, light groups) toggles on click
out of the box — no per-device setting needed; the device dialog shows out of the box — no per-device setting needed; the device dialog shows
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.39.0", "version": "1.40.0",
"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",
+118 -3
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, cutSegments, 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.39.0'; const CARD_VERSION = '1.40.0';
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';
@@ -3245,6 +3245,7 @@ class HouseplanCard extends LitElement {
})} })}
${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>
@@ -3539,7 +3540,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[] {
+49
View File
@@ -965,6 +965,55 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
return cutSegments(edges, cuts, eps); 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];
+15
View File
@@ -479,6 +479,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%);
+29
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';
@@ -734,3 +735,31 @@ test('resolveTapAction: pure lights toggle by default (v1.39.0)', () => {
// замок никогда // замок никогда
assert.equal(resolveTapAction('toggle', null, 'lock'), '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));
});