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