mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
A space carried an aspect ratio, and coordinates were normalised against it: x by the width, y by the height. Every geometric question therefore depended on a per-space number, and picking a canvas orientation was a decision the user had no reason to make. The render space is now NORM_W x NORM_W and a plan image is fitted into it by its OWN ratio, centred — wide plans get margins above and below, tall ones at the sides. Migration (geometry_migration.py, pure and unit-tested) runs once at setup under the write lock. Nothing about a drawing changes: the old box is padded out to a square and every coordinate re-expressed against it — rooms as rects and polygons, openings and their lengths, decor, view_box, and the marker positions in the separate layout store. In render units it is a uniform scale plus an offset, so angles and proportions are exact. cell_cm is scaled for tall plans, because the grid pitch is a fraction of the width: without it a wall would measure less than it does. is now dropped by the schema rather than accepted — a stale tab sending it would be sending coordinates from the old normalisation too, and honouring the field would not make them right. The demo fixture was migrated with the same transform, so the smokes exercise the new geometry rather than a square-native fake; six of them needed their render-space helpers updated and one its click coordinates. Not released — dev only, per the owner's instruction.
124 lines
8.8 KiB
JavaScript
124 lines
8.8 KiB
JavaScript
import { launch, checkAll, finish } 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 spId = c._space;
|
||
// включить glow-режим
|
||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||
c.requestUpdate(); await c.updateComplete;
|
||
await new Promise((r) => setTimeout(r, 250));
|
||
// 1) все комнаты залиты темнотой одинаково (независимо от ламп)
|
||
const rooms = [...sr().querySelectorAll('.room.styled')];
|
||
out.roomsDark = rooms.length > 0 && rooms.every((el) => {
|
||
const st = el.getAttribute('style') || '';
|
||
return st.includes('--room-fill:#0d1b2a');
|
||
});
|
||
// 2) пятна от включённых ламп
|
||
const litLight = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||
out.hasLitLight = !!litLight;
|
||
out.spots = sr().querySelectorAll('.glowlayer circle').length;
|
||
out.spotsMatchLights = out.spots === c._devices.filter((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on')).length;
|
||
// 3) выключение лампы убирает её пятно
|
||
const eid = litLight.entities.find((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on');
|
||
const st0 = c.hass.states[eid];
|
||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'off' } } };
|
||
await c.updateComplete;
|
||
out.spotGone = sr().querySelectorAll('.glowlayer circle').length === out.spots - 1;
|
||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
|
||
// 4) rgb-лампа красит градиент своим цветом (форсируем rgb у лампы)
|
||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'on', attributes: { ...st0.attributes, rgb_color: [255, 0, 0] } } } };
|
||
await c.updateComplete;
|
||
out.gradientColored = [...sr().querySelectorAll('defs radialGradient stop')].some((s2) => s2.getAttribute('stop-color') === 'rgb(255, 0, 0)');
|
||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
|
||
// 5) пятно обрезано комнатой (есть clipPath)
|
||
out.clipped = sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]').length > 0;
|
||
// 6) дверь в соседнюю комнату → в clip добавлен сектор (2 сабпути M)
|
||
// добавим дверь между r1 и r2 на общей стене x=0.55… найдём общую стену программно:
|
||
const spm = c._spaceModel();
|
||
const r1 = spm.rooms.find((r) => r.id === 'r1');
|
||
const r2 = spm.rooms.find((r) => r.id === 'r2');
|
||
// возьмём точку на границе r1, ближайшую к центру r2
|
||
const c2 = c._roomCenter(r2);
|
||
const poly1 = 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 H = 1000; // square canvas
|
||
const doorPt = (() => {
|
||
let best = null, bd = 1e9;
|
||
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||
const d2 = Math.hypot(x - c2[0], y - c2[1]);
|
||
if (d2 < bd) { bd = d2; best = [x, y]; }
|
||
}
|
||
return best;
|
||
})();
|
||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
|
||
c.requestUpdate(); await c.updateComplete;
|
||
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
|
||
const c1 = c._roomCenter(r1);
|
||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
|
||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
|
||
c.requestUpdate(); await c.updateComplete;
|
||
// теперь каждый под-контур — отдельный path внутри clipPath
|
||
const clipEls = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||
out.sectorAdded = clipEls.some((cp) => cp.querySelectorAll('path').length >= 2);
|
||
// и сектор в том же направлении обхода не выедает комнату: каждый path — один контур
|
||
out.singleSubpathPerPath = clipEls.every((cp) => [...cp.querySelectorAll('path')]
|
||
.every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1));
|
||
// у входной двери (наружу) сектора нет: дверь на внешней стене r1 (x=min)
|
||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
|
||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
|
||
c.requestUpdate(); await c.updateComplete;
|
||
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
|
||
// 6б) профиль градиента: плато до 80%, спад на внешних 20%
|
||
const stops = [...sr().querySelectorAll('defs radialGradient')][0];
|
||
const offs = stops ? [...stops.querySelectorAll('stop')].map((st2) => [st2.getAttribute('offset'), st2.getAttribute('stop-opacity')]) : [];
|
||
out.plateau80 = offs.length === 3 && offs[0][0] === '0%' && offs[1][0] === '70%'
|
||
&& offs[0][1] === offs[1][1] && offs[2][1] === '0';
|
||
// 7а) персональный радиус источника перекрывает глобальный
|
||
const litMarkerId = litLight.id;
|
||
c._serverCfg = { ...c._serverCfg, markers: [
|
||
...(c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId),
|
||
{ id: litMarkerId, binding: litLight.bindingKind === 'virtual' ? 'virtual' : litLight.bindingKind + ':' + litLight.bindingRef, glow_radius_cm: 150 },
|
||
] };
|
||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||
const rOwn = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
|
||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
|
||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
|
||
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
|
||
if (swDev) {
|
||
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
|
||
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
|
||
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
|
||
c._serverCfg = { ...c._serverCfg, markers: [
|
||
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
|
||
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
|
||
] };
|
||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
|
||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
|
||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
|
||
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
|
||
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
|
||
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
|
||
c.requestUpdate(); await c.updateComplete;
|
||
const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
|
||
return out;
|
||
});
|
||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||
checkAll(res, {
|
||
"spots": 1,
|
||
});
|
||
await finish(browser, res);
|