fix: polish device marker geometry and input

Issue: #213
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-20 11:00:22 +03:00
parent c749d68a4d
commit 0e5ee030fe
37 changed files with 1760 additions and 951 deletions
+1
View File
@@ -114,6 +114,7 @@ export function renderDeviceFace(
? html`<span class="habadge" title=${options.disabledTitle || ''} aria-hidden="true"><ha-icon icon="mdi:power-plug-off-outline"></ha-icon></span>`
: nothing}
<span class=${shellClasses} aria-hidden="true">
<span class="device-shell-frame"></span>
<span class="device-core">
${presentation.valueText != null
? html`<span class="valtext" title=${presentation.valueFullText || presentation.valueText}
+15
View File
@@ -0,0 +1,15 @@
/**
* Public `icon_size` values predate the visual package and therefore remain
* compatibility units. Issue #212 made their effective device core 10% more
* compact. Issue #213 moves that conversion to the surface boundary: the face
* receives the already-effective base and performs no late visual scaling.
*/
export const DEFAULT_DEVICE_BASE_SIZE = 2.25;
export const DEVICE_PREVIEW_BASE_PX = 48.6;
export function effectiveDeviceBaseSize(configuredSize: number): number {
if (!Number.isFinite(configuredSize) || configuredSize <= 0) {
return DEFAULT_DEVICE_BASE_SIZE;
}
return (configuredSize / 2.5) * DEFAULT_DEVICE_BASE_SIZE;
}
+3 -4
View File
@@ -16,7 +16,7 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
hassValue, lightColorOf, normalizeDeviceDisplay, stateIcon, valueWithUnit,
hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
type DeviceDisplayMode,
} from './logic';
import { resolveToggleIntent, toggleCoverEntity } from './device-toggle';
@@ -49,10 +49,9 @@ export function markerLqiBand(lqi: number): DeviceLqiBand {
return 'high';
}
/** Marker numbers are categorical. Room fill deliberately keeps logic.ts's gradient. */
/** Colour is continuous; the separate band remains semantic/a11y metadata. */
export function markerLqiColor(lqi: number): string {
const band = markerLqiBand(lqi);
return band === 'low' ? '#F0410C' : band === 'mid' ? '#F0A00C' : '#1DC21D';
return lqiColor(lqi);
}
export function deviceA11yState(presentation: Pick<
+12 -5
View File
@@ -214,6 +214,7 @@ import {
type RenderDeviceSnapshot,
} from './render-device-snapshot';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry';
import {
ModeTransitionController, viewportFromViewBox,
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
@@ -15662,6 +15663,7 @@ class HouseplanCard extends LitElement {
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const deviceBasePct = effectiveDeviceBaseSize(iconPct);
const view = this._viewOr(this._baseVb());
const floorView = this._floorView(view);
// Background around the plan (view/kiosk; editors keep their own canvas).
@@ -16077,9 +16079,10 @@ class HouseplanCard extends LitElement {
ordinary plan (pixel-identical) but grows with a plan drawn
past the old square, where a fixed 1000 would have shrunk
every marker to a dot. Same expression as the static
space-card, so the two renderers agree. The per-device
multiplier and the kiosk scales still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
space-card, so the two renderers agree. The compatibility
base is resolved before `iconCqw`; only the per-device and
kiosk multipliers still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderVacuums(devs, view)}
${this._renderVacFit(view)}
@@ -17811,10 +17814,14 @@ class HouseplanCard extends LitElement {
const point = this._scenePoint([px, py]);
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
return html`<div class="oplock ${deviceThemeClass(this._renderPlanHass)} ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (this._mode === 'view') this._openingInfo = o; }}>
<ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon>
<span class="oplock-shell" aria-hidden="true">
<span class="oplock-core">
<ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon>
</span>
</span>
</div>`;
})}`;
}
+1
View File
@@ -351,6 +351,7 @@ class HpDevicePreview extends LitElement {
pointer-events: none;
container-type: inline-size;
--icon-size: 54px;
--device-base-size: 48.6px;
}
.previewfit { position: absolute; left: 50%; top: 50%; transform-origin: center; }
.previewstage .dev { left: 0; top: 0; cursor: default; }
+6 -4
View File
@@ -38,6 +38,7 @@ import {
} from './device-presentation';
import { presentationSnapshotKey } from './render-device-snapshot';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
import type { VirtualLightSnapshot } from './virtual-light-state';
@@ -174,6 +175,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const colors = fillColorsOf(o.cfg.settings);
const cfgSize = o.iconSize ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const deviceBasePct = effectiveDeviceBaseSize(iconPct);
const planHass = o.registry ? activeRegistryHass(o.hass, o.registry) : o.hass;
const registryHass = o.registry ? fullRegistryHass(o.hass, o.registry) : o.hass;
@@ -545,10 +547,10 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
${''/* docs/CANVAS.md §6: the same expression as the full card. The
static card has no zoom, but its frame is the CONTENT now, so a
bare `iconPct` would make markers shrink relative to the plan the
tighter the frame got. `iconCqw` keeps the marker's footprint at
iconPct% of the plan's base unit, which is what it was when the
frame was the stored view_box. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw">${markers}${labels}</div>
tighter the frame got. `iconCqw` keeps the resolved device base
proportional to the plan's base unit, as it was when the frame
was the stored view_box. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, vb[2]).toFixed(3)}cqw">${markers}${labels}</div>
</div>
`;
}
+121 -37
View File
@@ -10,7 +10,6 @@ export const cardStyles = css`
--hp-accent: var(--primary-color, #3ea6ff);
--hp-on: #ffd45c;
--hp-open: #ff9f43;
--device-visual-factor: 0.9;
/* design tokens (UI chrome only). The icon/plan scale math stays on
--icon-size/--dev-size cqw units and never uses these. */
/* spacing scale, fact-based; stray 3/5/7/9/13/14px values are unified
@@ -762,31 +761,100 @@ export const cardStyles = css`
cursor: default;
}
.oplock {
--oplock-size: calc(var(--icon-size, 2.5cqw) * 0.62);
--oplock-core-size: calc(var(--oplock-size) / 1.26875);
--oplock-stroke-ratio: 0.01875;
--oplock-stroke-width: max(1px, calc(var(--oplock-core-size) * var(--oplock-stroke-ratio)));
--oplock-core-bg: light-dark(#fff, #252525);
--oplock-core-fg: light-dark(#252525, #fff);
--oplock-shell-stroke: light-dark(#BCBCBC, rgb(37 37 37 / 75%));
--oplock-shell-shadow:
0 calc(var(--oplock-core-size) * .025) calc(var(--oplock-core-size) * .05) rgb(37 40 45 / 12%),
0 calc(var(--oplock-core-size) * .1) calc(var(--oplock-core-size) * .175)
calc(var(--oplock-core-size) * -.025) rgb(37 40 45 / 18%);
--oplock-core-shadow: 0 0 0 0 transparent;
pointer-events: none; /* inert while editing; clickable in View (rule below) */
position: absolute;
transform: translate(-50%, -50%);
width: calc(var(--icon-size, 2.5cqw) * 0.62);
height: calc(var(--icon-size, 2.5cqw) * 0.62);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
width: var(--oplock-size);
height: var(--oplock-size);
display: grid;
place-items: center;
border: 0;
background: transparent;
z-index: 1;
}
.oplock.theme-light {
--oplock-core-bg: #fff;
--oplock-core-fg: #252525;
--oplock-shell-stroke: #BCBCBC;
}
.oplock.theme-dark {
--oplock-core-bg: #252525;
--oplock-core-fg: #fff;
--oplock-shell-stroke: rgb(37 37 37 / 75%);
--oplock-core-shadow:
inset 0 calc(var(--oplock-core-size) * .0125)
calc(var(--oplock-core-size) * .0125) rgb(255 255 255 / 70%);
}
.stage.mode-view .oplock {
pointer-events: auto;
cursor: pointer;
}
.oplock-shell {
box-sizing: border-box;
width: 100%;
height: 100%;
border: var(--oplock-stroke-width) solid var(--oplock-shell-stroke);
border-radius: 50%;
background: transparent;
box-shadow: var(--oplock-shell-shadow);
display: grid;
place-items: center;
pointer-events: none;
backdrop-filter: none;
}
.oplock-core {
width: var(--oplock-core-size);
height: var(--oplock-core-size);
border-radius: 50%;
background: var(--oplock-core-bg);
color: var(--oplock-core-fg);
box-shadow: var(--oplock-core-shadow);
display: grid;
place-items: center;
pointer-events: none;
}
.oplock ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
--mdc-icon-size: calc(var(--oplock-core-size) * 0.55);
display: flex;
line-height: 0;
}
.oplock.locked { color: #66d17a; border-color: #66d17a; }
.oplock.unlocked { color: var(--hp-open); border-color: var(--hp-open); }
.oplock.unknown { color: var(--hp-muted); }
.oplock.locked {
--oplock-core-bg: light-dark(#000, #252525);
--oplock-core-fg: #fff;
--oplock-shell-stroke: light-dark(#000, #252525);
}
.oplock.theme-light.locked {
--oplock-core-bg: #000;
--oplock-shell-stroke: #000;
}
.oplock.theme-dark.locked {
--oplock-core-bg: #252525;
--oplock-shell-stroke: #252525;
--oplock-stroke-ratio: .025;
}
.oplock.unlocked {
--oplock-core-bg: #F0A00C;
--oplock-core-fg: light-dark(#fff, #252525);
--oplock-shell-stroke: #F0A00C;
}
.oplock.theme-light.unlocked { --oplock-core-fg: #fff; }
.oplock.theme-dark.unlocked {
--oplock-core-fg: #252525;
--oplock-stroke-ratio: .025;
}
.oplock.unknown { --oplock-core-fg: var(--hp-muted); }
.btn.lockact {
width: 100%;
justify-content: center;
@@ -1881,15 +1949,14 @@ export const cardStyles = css`
}
.dev {
position: absolute;
/* per-device multiplier on top of the card-wide icon size */
--dev-size: calc(
var(--icon-size, 2.5cqw) * var(--dev-scale, 1) * var(--device-visual-factor, 0.9)
);
/* The surface resolves compatibility icon_size units once. The shared
face receives an effective base and only applies the per-device size. */
--dev-size: calc(var(--device-base-size, 2.25cqw) * var(--dev-scale, 1));
/* 101.5/80 is the package shell/core ratio, including its stroke. */
--device-shell-size: calc(var(--dev-size) * 1.26875);
--device-shell-inset: calc(var(--dev-size) * 0.134375);
--device-shell-stroke-ratio: 0.01875;
--device-shell-border-width: max(1px, calc(var(--dev-size) * var(--device-shell-stroke-ratio)));
--device-shell-pad: max(0px, calc(var(--dev-size) * 0.134375 - var(--device-shell-border-width)));
--device-core-bg: var(--card-background-color, #fff);
--device-core-fg: var(--primary-text-color, #252525);
--device-core-bg: light-dark(#fff, #252525);
@@ -1953,6 +2020,7 @@ export const cardStyles = css`
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: auto;
z-index: 3;
}
.device-shell {
position: absolute;
@@ -1960,43 +2028,59 @@ export const cardStyles = css`
display: flex;
align-items: center;
gap: calc(var(--dev-size) * 0.1);
padding: var(--device-shell-pad);
min-width: var(--device-shell-size);
min-height: var(--device-shell-size);
padding: 0;
min-width: var(--dev-size);
min-height: var(--dev-size);
border: 0;
background: transparent;
box-shadow: none;
transition: opacity .2s;
pointer-events: none;
}
.device-shell-frame {
position: absolute;
z-index: 0;
box-sizing: border-box;
inset: calc(var(--device-shell-inset) / -1);
border: var(--device-shell-border-width) solid var(--device-shell-stroke);
border-radius: calc(var(--device-shell-size) / 2);
/* A saturating radius is resolved from the final border box. Using a
second fractional length here lets border-box and radius quantise on
different sides of a device pixel at some zoom/DPR combinations. */
border-radius: 9999px;
background: transparent;
box-shadow: var(--device-shell-shadow);
transition: border-color .15s, box-shadow .15s, opacity .2s;
pointer-events: none;
pointer-events: auto;
/* Normative production fallback: never add a per-marker backdrop blur. */
backdrop-filter: none;
}
.device-shell:not(.with-values) {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
left: 0;
top: 0;
}
.device-shell:not(.with-values) .device-shell-frame {
border-radius: 50%;
}
.device-shell.with-values.pos-right {
left: calc(50% - var(--device-shell-size) / 2);
left: 0;
top: 50%;
transform: translateY(-50%);
}
.device-shell.with-values.pos-left {
right: calc(50% - var(--device-shell-size) / 2);
right: 0;
top: 50%;
transform: translateY(-50%);
flex-direction: row-reverse;
}
.device-shell.with-values.pos-bottom {
left: 50%;
top: calc(50% - var(--device-shell-size) / 2);
top: 0;
transform: translateX(-50%);
flex-direction: column;
}
.device-shell.with-values.pos-top {
left: 50%;
bottom: calc(50% - var(--device-shell-size) / 2);
bottom: 0;
transform: translateX(-50%);
flex-direction: column-reverse;
}
@@ -2018,6 +2102,7 @@ export const cardStyles = css`
0 0 0 var(--device-ring-width) var(--device-ring-color);
line-height: 0;
transition: background .15s, color .15s, box-shadow .15s, opacity .2s;
pointer-events: none;
}
.device-sections {
display: flex;
@@ -2026,6 +2111,7 @@ export const cardStyles = css`
justify-content: center;
gap: calc(var(--dev-size) * .08);
min-width: 0;
pointer-events: none;
}
.device-shell.pos-top .device-sections,
.device-shell.pos-bottom .device-sections {
@@ -2036,7 +2122,7 @@ export const cardStyles = css`
scale the GLYPH with its badge. Pinned to the base size, "make this
icon bigger" grew an empty box around a default-size glyph (user
report via the owner, 2026-07-29). */
--mdc-icon-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.5);
--mdc-icon-size: calc(var(--dev-size, var(--device-base-size, 2.25cqw)) * 0.55);
display: flex;
align-items: center;
justify-content: center;
@@ -2150,7 +2236,7 @@ export const cardStyles = css`
.drawwall.invalid .rangehint { color: var(--error-color, #db4437); opacity: 1; }
.stage.markup.tool-partition,
.stage.markup.tool-column { cursor: crosshair; }
.dev.virtual .device-shell {
.dev.virtual .device-shell-frame {
border-style: dashed;
}
/* "hide from plan" flag, shown only in the device editor with the
@@ -2162,7 +2248,7 @@ export const cardStyles = css`
opacity: 0.6;
color: var(--hp-accent);
}
.dev.ghost .device-shell {
.dev.ghost .device-shell-frame {
border-style: dashed;
border-color: var(--hp-accent);
box-shadow: none;
@@ -2178,7 +2264,7 @@ export const cardStyles = css`
opacity: 0.62;
color: var(--secondary-text-color, #9aa0aa);
}
.dev.ghost.ha-disabled .device-shell {
.dev.ghost.ha-disabled .device-shell-frame {
border-color: var(--secondary-text-color, #9aa0aa);
}
.dev.ghost.ha-disabled .device-core {
@@ -2565,9 +2651,7 @@ export const cardStyles = css`
position: absolute;
/* the base badge, but round and 20% smaller — the owner's wording:
«иконка похожа на иконку базы, только круглая и чуть меньше» */
--puck-size: calc(
var(--icon-size, 2.5cqw) * var(--device-visual-factor, 0.9) * 0.8
);
--puck-size: calc(var(--device-base-size, 2.25cqw) * 0.8);
width: var(--puck-size);
height: var(--puck-size);
border-radius: 50%;