mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix(2.5d): room metrics keep their distance from the name at every zoom (#665)
The raised room label in 2.5D View sized its own box to the 44 px touch minimum (min-height + justify-content: center). The name therefore sat centred in 44 px while the metrics row, placed absolutely from the label bottom, hung (44 - name height) / 2 below it: 18.3 px at a 9 px name and 6.4 px at a 39 px one on the demo stand, against a constant 0.1 name height on the flat plan. The 44 x 44 px floor moves into an invisible ::before (the door-lock pattern), z-index -1 inside the label stacking context; the label box is sized by its text again. demo/smoke_iso_room_label_metrics.mjs measures gap / name height at two cameras (font x4) in Flat and 2.5D, and the 44 px floor and area-link hit targets on labels no raised device covers. Mutant iso-room-label-44-box-centres-name. Screenshots: 11 frames pixel-identical, fingerprint only. Issue: #665 User-Visible: yes
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
// #665: in 2.5D the metrics row under a room name keeps the same distance
|
||||
// from the name at every zoom — the same share of the name height as in Flat.
|
||||
//
|
||||
// The regression: the raised label's 44 px touch floor was the label box
|
||||
// itself (min-height + centring), so the name sat centred in 44 px and the
|
||||
// absolutely placed metrics row hung (44 - name height) / 2 below it. The
|
||||
// distance therefore shrank as zooming in grew the font. The floor now lives
|
||||
// in ::before; this smoke measures the painted boxes and the hit target.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.renderRoot;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
// Metrics rows need an explicit opt-in; the demo fixture shows names only.
|
||||
for (const config of [card._serverCfg, card._config].filter(Boolean)) {
|
||||
for (const space of config.spaces || []) {
|
||||
space.settings = { ...(space.settings || {}), show_names: true, label_temp: true, label_light: true };
|
||||
}
|
||||
}
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
const labels = () => [...root().querySelectorAll('.roomlabel')]
|
||||
.filter((label) => label.querySelector('.rlname') && label.querySelector('.rlmetrics'));
|
||||
const measure = () => labels().map((label) => {
|
||||
const name = label.querySelector('.rlname').getBoundingClientRect();
|
||||
const metrics = label.querySelector('.rlmetrics').getBoundingClientRect();
|
||||
return { id: label.dataset.id, name: name.height, gap: metrics.top - name.bottom };
|
||||
});
|
||||
// Two cameras around the same centre: the label font follows the zoom.
|
||||
const atZooms = async () => {
|
||||
const base = { ...card._view };
|
||||
const rows = [];
|
||||
for (const factor of [1.6, 0.35]) {
|
||||
const w = base.w * factor;
|
||||
const h = base.h * factor;
|
||||
card._view = { x: base.x + (base.w - w) / 2, y: base.y + (base.h - h) / 2, w, h };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
await frame();
|
||||
rows.push(measure());
|
||||
}
|
||||
card._view = base;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
return rows;
|
||||
};
|
||||
|
||||
await window.__hpTest.setVolumetricView(false);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const flat = await atZooms();
|
||||
await window.__hpTest.setVolumetricView(true);
|
||||
if (typeof card._ensureIsoSceneRuntime === 'function') await card._ensureIsoSceneRuntime();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const iso = await atZooms();
|
||||
|
||||
// Touch floor (AC2): a raised label still owns 44 x 44 px around its centre,
|
||||
// and the area link inside it stays a separate, clickable target. Raised
|
||||
// device tiles are deliberately above names (z-index 2), so a label a device
|
||||
// happens to overlap in this fixture cannot answer: judge the labels no
|
||||
// device covers.
|
||||
const cls = (el) => el ? `${el.tagName.toLowerCase()}.${[...el.classList].join('.')}` : null;
|
||||
const underDevice = (el) => !!el?.closest?.('.dev, .oplock');
|
||||
const probes = [...root().querySelectorAll('.stage.projection-iso.mode-view .roomlabel')].map((label) => {
|
||||
const rect = label.getBoundingClientRect();
|
||||
const pseudo = getComputedStyle(label, '::before');
|
||||
const centre = [rect.left + rect.width / 2, rect.top + rect.height / 2];
|
||||
const hits = [[0, 0], [-21, 0], [21, 0], [0, -21], [0, 21]]
|
||||
.map(([dx, dy]) => root().elementFromPoint(centre[0] + dx, centre[1] + dy));
|
||||
const areaLink = label.querySelector('.rlgo');
|
||||
const linkRect = areaLink?.getBoundingClientRect();
|
||||
const linkHit = linkRect
|
||||
? root().elementFromPoint(linkRect.left + linkRect.width / 2, linkRect.top + linkRect.height / 2)
|
||||
: null;
|
||||
return {
|
||||
id: label.dataset.id,
|
||||
boxHeight: rect.height,
|
||||
floor: { width: Number.parseFloat(pseudo.width), height: Number.parseFloat(pseudo.height) },
|
||||
covered: hits.some(underDevice) || underDevice(linkHit),
|
||||
floorHits: hits.map((hit) => !!hit && (hit === label || label.contains(hit))),
|
||||
link: areaLink ? (!!linkHit && (linkHit === areaLink || areaLink.contains(linkHit))) : null,
|
||||
hitNames: hits.map(cls),
|
||||
};
|
||||
});
|
||||
const free = probes.filter((probe) => !probe.covered);
|
||||
|
||||
const ratio = (row) => row.gap / row.name;
|
||||
return {
|
||||
flat,
|
||||
iso,
|
||||
probes,
|
||||
freeLabels: free.length,
|
||||
floorIs44: free.length > 0 && free.every((probe) => probe.floor.width >= 44 && probe.floor.height >= 44),
|
||||
floorOwnsTheTarget: free.length > 0 && free.every((probe) => probe.floorHits.every(Boolean)),
|
||||
areaLinkStaysClickable: free.some((probe) => probe.link === true)
|
||||
&& free.every((probe) => probe.link !== false),
|
||||
ratios: {
|
||||
flat: flat.map((rows) => rows.map(ratio)),
|
||||
iso: iso.map((rows) => rows.map(ratio)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const pairs = out.iso.flatMap((rows, zoom) => rows.map((row, index) => ({
|
||||
zoom, id: row.id, iso: row.gap / row.name, flat: out.flat[zoom][index].gap / out.flat[zoom][index].name,
|
||||
})));
|
||||
check('labelsMeasured', pairs.length >= 2 && out.iso.every((rows) => rows.length > 0));
|
||||
// The font really changed between the two cameras, or the test proves nothing.
|
||||
check('zoomChangesTheFont', out.iso[1][0].name >= out.iso[0][0].name * 2);
|
||||
// AC1: the 2.5D share equals the Flat share at each zoom, and is the same at both zooms.
|
||||
check('gapMatchesFlatAtEveryZoom', pairs.every((pair) => Math.abs(pair.iso - pair.flat) <= 0.02));
|
||||
check('gapIsZoomIndependent', out.iso[0].every((row, index) =>
|
||||
Math.abs(row.gap / row.name - out.iso[1][index].gap / out.iso[1][index].name) <= 0.02));
|
||||
// AC2: 44 px floor kept, as painted-box-independent hit testing.
|
||||
check('freeLabelsExist', out.freeLabels >= 1);
|
||||
check('floorIs44', out.floorIs44);
|
||||
check('floorOwnsTheTarget', out.floorOwnsTheTarget);
|
||||
check('areaLinkStaysClickable', out.areaLinkStaysClickable);
|
||||
await finish(browser, { ...out, pairs });
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- In the 2.5D plan the metrics row under a room name (temperature, humidity,
|
||||
Zigbee signal, lights) no longer drifts while zooming: it keeps the same
|
||||
distance from the name at every zoom, exactly as on the flat plan. The room
|
||||
label keeps its 44 × 44 px touch target
|
||||
([#665](https://github.com/Matysh/houseplan-card/issues/665)).
|
||||
- The close **×** of the active editor now sits inside the highlighted tab: the
|
||||
accent fill of the active Plan, Devices or Decor tab continues over the
|
||||
cross, which is drawn in the tab's text colour, and the keyboard focus ring
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- На объёмном плане (2.5D) строка показателей под названием комнаты
|
||||
(температура, влажность, сигнал Zigbee, свет) больше не «плавает» при зуме:
|
||||
расстояние до названия одно и то же на любом масштабе, как на плоском
|
||||
плане. Цель касания подписи комнаты по-прежнему 44 × 44 px
|
||||
([#665](https://github.com/Matysh/houseplan-card/issues/665)).
|
||||
- Крестик **×** активного редактора теперь внутри подсвеченной вкладки:
|
||||
акцентная заливка вкладки «План», «Устройства» или «Декор» продолжается под
|
||||
крестиком, сам крестик нарисован цветом текста вкладки, а рамка фокуса с
|
||||
|
||||
@@ -324,6 +324,9 @@ byte-for-byte unchanged. Side-by-side acceptance frames:
|
||||
- Light or dark floor is decided per room: the room fill at its opacity over
|
||||
the plan paper, luma > 0.55 is light (`isoLightFloorRooms`). A marker belongs
|
||||
to the room of its overlay owner or, without walls, to the room under it.
|
||||
- A raised room label keeps its 44 × 44 px touch floor in an invisible
|
||||
`::before`, like door locks; the label box itself is sized by its text, so
|
||||
the metrics row keeps the Flat distance from the name at every zoom (#665).
|
||||
- Hover / focus-visible / selected / alert frames hug tile and edge
|
||||
(bbox + 0.075 D per side, + edge height) and float with the tile: `#0C82F0`,
|
||||
`#0C82F0`, `#F0A00C`, `#F0410C`, priority Alert > Focus > Selected > Hover.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"oxipng": null,
|
||||
"sourceFingerprint": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceFingerprint": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"captureScriptSha256": "e2d14d89de793011615662af7c83c433768d4761f6cec7f0b3860c797494299e",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "1bde1bff8d4ab5f197320b54ef57186b3c07cf2121d5414ada63ce357826bfa8"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "e106ff9db6c96adb08beba12cc299a88ac5fa47c079be316f185e5657bc656f0"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "9b0db923fa7c8aff783b5c7050ee5b410b1b04df81a9e48bbc8111a7d0d47e76"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "198c084c0a45aa3afdf79181b80d8bb0347c95f3248b4fac9e05d69eb8a35f8f"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "e9b88d65dca74133646aa849d47bc09ad85eb8b16a168c4df6930e8f5856b5f5"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "44beadd61365ac1730c1efde846e528bfe92d8935fd167f1b6bf3bbad6af8f33"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -103,7 +103,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "4ec8c6df671742b90ca55222917076c47c5035a6df0587282e60873c0ce5f64e"
|
||||
},
|
||||
"pdf-export": {
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"theme": "light",
|
||||
"language": "en",
|
||||
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
|
||||
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
|
||||
"imageSha256": "ad3d735135b180ce015c9cda07fdcec08d7c8707abe0665734ef7b450a8a7093"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3391,6 +3391,16 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: "",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'iso-room-label-44-box-centres-name',
|
||||
guard: 'node demo/smoke_iso_room_label_metrics.mjs',
|
||||
because: '#665: sizing the raised label box to 44 px centres the name in it and pushes the metrics row away by (44 - name) / 2, so the gap drifts with zoom',
|
||||
patches: [{
|
||||
file: 'src/styles/plan.styles.ts',
|
||||
find: " .stage.projection-iso.mode-view .roomlabel {\n box-sizing: border-box;\n",
|
||||
replace: " .stage.projection-iso.mode-view .roomlabel {\n box-sizing: border-box;\n min-height: 44px;\n justify-content: center;\n",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'static-card-descendants-hit-testable',
|
||||
guard: 'node demo/smoke_space_card.mjs',
|
||||
|
||||
@@ -691,15 +691,29 @@ export const planStyles = css`
|
||||
}
|
||||
.stage.projection-iso.mode-view .roomlabel {
|
||||
box-sizing: border-box;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
text-shadow: none;
|
||||
filter: none;
|
||||
-webkit-text-stroke: 0 transparent;
|
||||
/* #649 3b: the configured room label colour (inline, as in Flat) is
|
||||
kept in both themes; 2.5D only drops stroke, shadow and halo. */
|
||||
}
|
||||
/* #665: the 44 x 44 px touch floor of a raised room label is an invisible
|
||||
pseudo-element, as on door locks: sizing the label box itself centred the
|
||||
name in 44 px and pushed the absolutely placed metrics row away from it
|
||||
by (44 - name height) / 2, so the metrics drifted as the zoom changed the
|
||||
font. z-index -1 paints the floor below the label content, inside the
|
||||
label's own stacking context (z-index 1). */
|
||||
.stage.projection-iso.mode-view .roomlabel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: max(44px, 100%);
|
||||
height: max(44px, 100%);
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: -1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Interactive raised overlays stay above passive room names. Collision
|
||||
placement never needs to move or promote a label to make a device usable. */
|
||||
.stage.projection-iso.mode-view .dev,
|
||||
|
||||
Reference in New Issue
Block a user