mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
fix: polish device marker geometry and input
Issue: #213 User-Visible: yes
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user