mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
v1.60.1-beta.1: harden editing and device state
This commit is contained in:
+21
-1
@@ -72,6 +72,13 @@ const IDLE_STATES = new Set([
|
||||
'completed', 'stopped', 'ready', 'sleeping',
|
||||
]);
|
||||
|
||||
/** Standard HA HVAC modes which mean the climate entity is enabled. The
|
||||
* entity's own `hvac_modes` attribute extends this set for integrations with
|
||||
* custom modes; `off`/idle states are always excluded. */
|
||||
const CLIMATE_ENABLED_MODES = new Set([
|
||||
'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only',
|
||||
]);
|
||||
|
||||
const unavailable = (state: string): boolean =>
|
||||
state === '' || state === 'unknown' || state === 'unavailable' || state === '__missing__';
|
||||
|
||||
@@ -147,7 +154,20 @@ export function entityVisualSample(hass: any, eid: string): EntityVisualSample {
|
||||
|
||||
if (domain === 'climate') {
|
||||
const action = workAction(st.attributes);
|
||||
return WORKING_STATES.has(action)
|
||||
// `hvac_action` (or an equivalent action attribute) is the precise answer
|
||||
// when an integration exposes it: a thermostat may be in heat mode while
|
||||
// currently idle. Some valid climate integrations expose only the entity
|
||||
// state/HVAC mode, though. In that case use HA's own `hvac_modes` contract
|
||||
// as the best available enabled-state fallback instead of leaving every
|
||||
// non-off air conditioner permanently neutral.
|
||||
const advertisedModes = Array.isArray(st.attributes?.hvac_modes)
|
||||
? st.attributes.hvac_modes.map(lower) : [];
|
||||
const enabledMode = !IDLE_STATES.has(state) && (
|
||||
CLIMATE_ENABLED_MODES.has(state)
|
||||
|| WORKING_STATES.has(state)
|
||||
|| advertisedModes.includes(state)
|
||||
);
|
||||
return (action ? WORKING_STATES.has(action) : enabledMode)
|
||||
? { ...base, status: 'working', activity: 'running' }
|
||||
: base;
|
||||
}
|
||||
|
||||
+56
-12
@@ -181,12 +181,32 @@ export function effectiveMarkerControls(
|
||||
controls: readonly string[] | null | undefined,
|
||||
ownEntities: readonly string[] = [],
|
||||
): string[] {
|
||||
const own = new Set(ownEntities);
|
||||
if (binding?.startsWith('entity:')) own.add(binding.slice('entity:'.length));
|
||||
// An entity marker owns only its exact binding. Registry rebuilds may attach
|
||||
// sibling entities from the parent device to DevItem; treating every sibling
|
||||
// as self would silently drop an explicitly configured external target.
|
||||
const own = binding?.startsWith('entity:')
|
||||
? new Set([binding.slice('entity:'.length)])
|
||||
: new Set(ownEntities);
|
||||
return [...new Set(controls || [])]
|
||||
.filter((eid) => isControllable(eid) && !own.has(eid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Before `is_light` existed, putting a marker's own switch into `controls`
|
||||
* was the supported way to say that the relay drives a real fixture. Keep that
|
||||
* intent alive until Save/Optimize converts the legacy representation.
|
||||
*/
|
||||
export function hasLegacySelfLightIntent(
|
||||
binding: string | null | undefined,
|
||||
controls: readonly string[] | null | undefined,
|
||||
ownEntities: readonly string[] = [],
|
||||
): boolean {
|
||||
const own = binding?.startsWith('entity:')
|
||||
? new Set([binding.slice('entity:'.length)])
|
||||
: new Set(ownEntities);
|
||||
return (controls || []).some((eid) => eid.startsWith('switch.') && own.has(eid));
|
||||
}
|
||||
|
||||
function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom): boolean {
|
||||
if (typeof room === 'string') return d.area === room;
|
||||
// An explicit marker-to-room binding is more precise than the HA area and
|
||||
@@ -196,25 +216,48 @@ function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom):
|
||||
return !!room.area && d.area === room.area;
|
||||
}
|
||||
|
||||
function lightEntitiesOf(hass: any, d: LightSourceDevice): { eids: string[]; via: ResolvedLightSource['via'] } {
|
||||
interface LightEntityCandidate {
|
||||
eid: string;
|
||||
via: ResolvedLightSource['via'];
|
||||
}
|
||||
|
||||
function forcedLightEntityOf(d: LightSourceDevice): string | null {
|
||||
const bound = d.marker?.binding?.startsWith('entity:')
|
||||
? d.marker.binding.slice('entity:'.length) : null;
|
||||
const controllable = d.entities.filter(isControllable);
|
||||
const primary = d.primary && isControllable(d.primary) ? d.primary : null;
|
||||
// An entity-bound legacy self-control names the physical relay exactly.
|
||||
// Do not let a different registry-derived primary entity steal that intent.
|
||||
return (bound && isControllable(bound) ? bound : null) || primary || controllable[0] || null;
|
||||
}
|
||||
|
||||
function lightEntitiesOf(hass: any, d: LightSourceDevice): LightEntityCandidate[] {
|
||||
const controls = effectiveMarkerControls(
|
||||
d.marker?.binding,
|
||||
d.controls ?? d.marker?.controls,
|
||||
d.entities,
|
||||
);
|
||||
if (controls.length) return { eids: controls, via: 'controls' };
|
||||
const out: LightEntityCandidate[] = controls.map((eid) => ({ eid, via: 'controls' }));
|
||||
|
||||
if (d.marker?.is_light === true) {
|
||||
if (d.marker?.is_light === true || hasLegacySelfLightIntent(
|
||||
d.marker?.binding, d.marker?.controls, d.entities,
|
||||
)) {
|
||||
// A forced source is a smart switch (or light) driving real fixtures.
|
||||
// Prefer its primary entity, but never treat measurements or service
|
||||
// entities as light switches merely because they belong to the device.
|
||||
const controllable = d.entities.filter(isControllable);
|
||||
const primary = d.primary && isControllable(d.primary) ? d.primary : null;
|
||||
// The marker is one physical source, so do not count its auxiliary
|
||||
// controllable entities as additional lamps in room statistics.
|
||||
return { eids: primary ? [primary] : controllable.slice(0, 1), via: 'forced' };
|
||||
const forced = forcedLightEntityOf(d);
|
||||
if (forced && !out.some((candidate) => candidate.eid === forced)) {
|
||||
out.push({ eid: forced, via: 'forced' });
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit external controls and the marker's own explicit source are
|
||||
// additive. Automatic discovery remains the fallback only when neither was
|
||||
// configured, so a device status LED still cannot leak into the room light set.
|
||||
if (out.length) return out;
|
||||
|
||||
// Automatic light discovery describes the DEVICE, not every auxiliary
|
||||
// entity it happens to expose. TVs, soundbars, air cleaners and similar
|
||||
// devices often publish a light.* for a status LED / display illumination;
|
||||
@@ -223,8 +266,9 @@ function lightEntitiesOf(hass: any, d: LightSourceDevice): { eids: string[]; via
|
||||
// Explicit controls/is_light above still override it when the user says the
|
||||
// auxiliary light really is a plan light.
|
||||
const role = d.primary || resolvedDeviceStateEntities(hass, d.entities)[0];
|
||||
if (role && !role.startsWith('light.')) return { eids: [], via: 'light' };
|
||||
return { eids: d.entities.filter((eid) => eid.startsWith('light.')), via: 'light' };
|
||||
if (role && !role.startsWith('light.')) return [];
|
||||
return d.entities.filter((eid) => eid.startsWith('light.'))
|
||||
.map((eid) => ({ eid, via: 'light' as const }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,8 +286,8 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
const seen = new Set<string>();
|
||||
for (const d of devices) {
|
||||
if (d.hidden || (room != null && !lightSourceBelongsToRoom(d, room))) continue;
|
||||
const { eids, via } = lightEntitiesOf(hass, d);
|
||||
for (const eid of eids) {
|
||||
const candidates = lightEntitiesOf(hass, d);
|
||||
for (const { eid, via } of candidates) {
|
||||
if (!eid || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
out.push({ eid, device: d, via, on: hass.states[eid]?.state === 'on' });
|
||||
|
||||
+153
-59
@@ -32,7 +32,7 @@ import {
|
||||
} from './logic';
|
||||
import {
|
||||
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
|
||||
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
|
||||
simplifyPoly, polyIsSimple, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
|
||||
} from './resize';
|
||||
import {
|
||||
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
areaTemp, areaHum, sourceValue, areaClimateMap,
|
||||
resolvedLightSources, resolvedLightState, resolvedLightStats,
|
||||
resolvedDeviceStateEntities, removedPlanBindings, isRemovedPlanEntity,
|
||||
deletePlanMarkerRecords, effectiveMarkerControls, resolveIcon,
|
||||
deletePlanMarkerRecords, effectiveMarkerControls, hasLegacySelfLightIntent, resolveIcon,
|
||||
type AreaClimate,
|
||||
} from './devices';
|
||||
import type {
|
||||
@@ -108,9 +108,10 @@ import {
|
||||
type DecorBox, type SnapGeometry,
|
||||
} from './editors/decor/geometry';
|
||||
|
||||
const CARD_VERSION = '1.60.0';
|
||||
const CARD_VERSION = '1.60.1-beta.1';
|
||||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
const CELL_CM_MIN = 0.1;
|
||||
const CELL_CM_MAX = 1000;
|
||||
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
|
||||
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
|
||||
@@ -530,13 +531,29 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
|
||||
/**
|
||||
* Which way the plan should fly out when the space changes. Empty means no
|
||||
* animation — a direct pick from the tabs should not slide anywhere.
|
||||
*/
|
||||
/** Which way the incoming plan moves when the active space changes. */
|
||||
private _slide: '' | 'left' | 'right' = '';
|
||||
private _slideTimer?: number;
|
||||
|
||||
/** Short shared transition for View ↔ editor and editor ↔ editor changes. */
|
||||
private _navMotion: '' | 'enter' | 'exit' | 'swap' = '';
|
||||
private _navMotionTimer?: number;
|
||||
/** Last editor kept in the collapsing chrome so leaving it can animate out. */
|
||||
private _editorChromeMode: 'plan' | 'devices' | 'decor' = 'plan';
|
||||
|
||||
private _startNavMotion(kind: 'enter' | 'exit' | 'swap'): void {
|
||||
clearTimeout(this._navMotionTimer);
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
|
||||
this._navMotion = '';
|
||||
return;
|
||||
}
|
||||
this._navMotion = kind;
|
||||
this._navMotionTimer = window.setTimeout(() => {
|
||||
this._navMotion = '';
|
||||
this.requestUpdate();
|
||||
}, 190);
|
||||
}
|
||||
|
||||
/** Change the space with the usual sideways transition. */
|
||||
private _slideTo(id: string, dir: 'left' | 'right'): void {
|
||||
if (id === this._space) return;
|
||||
@@ -547,11 +564,23 @@ class HouseplanCard extends LitElement {
|
||||
if (reduce) return;
|
||||
this._slide = dir;
|
||||
clearTimeout(this._slideTimer);
|
||||
// long enough to be read as motion, short enough not to be in the way
|
||||
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 260);
|
||||
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 190);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Direct space tabs use the same motion as swipe/carousel navigation. */
|
||||
private _pickSpace(id: string): void {
|
||||
if (id === this._space) return;
|
||||
const ids = this._model.map((sp) => sp.id);
|
||||
const from = ids.indexOf(this._space);
|
||||
const to = ids.indexOf(id);
|
||||
this._navApplied = true;
|
||||
this._showFar = false; // the hint is per space (docs/CANVAS.md §4.1)
|
||||
this._frame = null;
|
||||
this._slideTo(id, from >= 0 && to < from ? 'right' : 'left');
|
||||
this._saveNav();
|
||||
}
|
||||
|
||||
private _cycleTick(): void {
|
||||
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
|
||||
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
|
||||
@@ -1040,6 +1069,7 @@ class HouseplanCard extends LitElement {
|
||||
this._signer.dispose();
|
||||
clearTimeout(this._toastTimer);
|
||||
clearTimeout(this._slideTimer);
|
||||
clearTimeout(this._navMotionTimer);
|
||||
clearTimeout(this._bootTimer);
|
||||
this._bootTimer = undefined; // AUD-1552-01: a cleared id must not block the reconnect watcher
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
@@ -1077,6 +1107,10 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._warmReviveTimer);
|
||||
this._warmReviveTimer = undefined;
|
||||
this._warmRelease();
|
||||
// R1: the rAF is the only normal owner that clears this flag. Reset only
|
||||
// after the disconnect snapshot (which must still skip unstable geometry),
|
||||
// so a same-element reattach can start fresh instead of keeping the veil.
|
||||
this._resumeSettling = false;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -1109,13 +1143,20 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
const inField = !!target?.closest?.('input, textarea, select, [contenteditable="true"]');
|
||||
// At the window listener `target` may be retargeted to the card host by
|
||||
// Shadow DOM. The composed path still contains the actual focused field.
|
||||
const inField = (e.composedPath?.() || [e.target]).some((node: any) =>
|
||||
node?.matches?.('input, textarea, select, [contenteditable="true"]'),
|
||||
);
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const key = e.key.toLowerCase();
|
||||
// `key` becomes «я» on a Russian layout. `code` keeps Ctrl/Cmd+Z and
|
||||
// Ctrl/Cmd+Y tied to the expected physical shortcuts in every layout.
|
||||
const isZ = e.code === 'KeyZ' || key === 'z';
|
||||
const isY = e.code === 'KeyY' || key === 'y';
|
||||
const redo = mod && ((isZ && e.shiftKey) || isY);
|
||||
const undo = mod && isZ && !e.shiftKey;
|
||||
if (this._mode === 'decor') {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const key = e.key.toLowerCase();
|
||||
const redo = mod && (key === 'y' || (key === 'z' && e.shiftKey));
|
||||
const undo = mod && key === 'z' && !e.shiftKey;
|
||||
if ((undo || redo) && inField) return;
|
||||
if (redo) { e.preventDefault(); this._redoGeometry(); return; }
|
||||
if (undo) {
|
||||
@@ -1148,10 +1189,6 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
if (!this._markup) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const key = e.key.toLowerCase();
|
||||
const redo = mod && (key === 'y' || (key === 'z' && e.shiftKey));
|
||||
const undo = mod && key === 'z' && !e.shiftKey;
|
||||
if ((undo || redo) && inField) return; // keep native text-field history
|
||||
if (redo) {
|
||||
e.preventDefault();
|
||||
@@ -3500,11 +3537,12 @@ class HouseplanCard extends LitElement {
|
||||
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
|
||||
if (this._kiosk && mode !== 'view') return; // wall devices never edit
|
||||
if (this._mode === mode) return;
|
||||
this._bootSoftCancel(); // editor bars change the stage height DELIBERATELY — snap, no glide
|
||||
this._bootSoftCancel(); // navigation owns its own short, bounded transition
|
||||
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
|
||||
this._showToast(this._t('toast.markup_needs_server'));
|
||||
return;
|
||||
}
|
||||
const previousMode = this._mode;
|
||||
// A live decor transform is a transaction. Switching tabs must cancel and
|
||||
// restore it, not merely forget its pointer record after the config has
|
||||
// already been mutated by move/resize.
|
||||
@@ -3522,6 +3560,8 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
}
|
||||
this._mode = mode;
|
||||
this._editorChromeMode = mode === 'view' ? previousMode as 'plan' | 'devices' | 'decor' : mode;
|
||||
this._startNavMotion(previousMode === 'view' ? 'enter' : mode === 'view' ? 'exit' : 'swap');
|
||||
if (baseChanges) {
|
||||
// refit against the new base: the editors measure from the full square,
|
||||
// the view from the content frame — a view clamped to one is nonsense
|
||||
@@ -3873,6 +3913,54 @@ class HouseplanCard extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** A room ring may meet only at the endpoints of neighbouring edges. */
|
||||
private _contourSelfIntersects(poly: number[][]): boolean {
|
||||
if (!polyIsSimple(poly)) return true; // transverse crossings
|
||||
const n = poly.length;
|
||||
const eps = 0.001;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % n];
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
// Neighbours deliberately share one endpoint, including first ↔ last.
|
||||
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
|
||||
const c = poly[j], d = poly[(j + 1) % n];
|
||||
const cd = [c[0], c[1], d[0], d[1]];
|
||||
const ab = [a[0], a[1], b[0], b[1]];
|
||||
// This also catches a closing edge passing through an older vertex and
|
||||
// collinear overlap, which a proper-crossing test intentionally omits.
|
||||
if (distToSegment(a, cd) <= eps || distToSegment(b, cd) <= eps ||
|
||||
distToSegment(c, ab) <= eps || distToSegment(d, ab) <= eps) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate and close the draft without using the closing click as a vertex. */
|
||||
private _closeRoomContour(showMinimumError = false): void {
|
||||
// Three placed vertices mean two existing edges; the closing edge becomes
|
||||
// the third one. Anything shorter cannot enclose a room.
|
||||
if (this._path.length < 3) {
|
||||
if (showMinimumError) this._showToast(this._t('toast.contour_min_edges'));
|
||||
return;
|
||||
}
|
||||
if (this._contourSelfIntersects(this._path) || polygonArea(this._path) <= 1e-6) {
|
||||
this._showToast(this._t('toast.contour_cannot_close'));
|
||||
return; // keep the draft editable
|
||||
}
|
||||
// A contour can enclose an existing room without any vertex inside it.
|
||||
const clash = this._overlapRoom(this._path);
|
||||
if (clash) {
|
||||
this._showToast(this._t('toast.room_overlap', { name: clash.name || '' }));
|
||||
return;
|
||||
}
|
||||
this._path = [...this._path, [...this._path[0]]];
|
||||
this._cursorPt = null;
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
this._resetRoomDialogFields();
|
||||
this._roomDialog = true;
|
||||
}
|
||||
|
||||
private _markupClick(ev: MouseEvent): void {
|
||||
if (this._vacFit) return; // the fit overlay owns all pointer input
|
||||
if (!this._markup) return;
|
||||
@@ -3924,6 +4012,11 @@ class HouseplanCard extends LitElement {
|
||||
// draw: clicks on grid points build the outline. Nothing is written to the config
|
||||
// until the contour closes — an abandoned outline leaves no lines behind.
|
||||
const pt = this._snap(raw);
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
this._closeRoomContour(true);
|
||||
return;
|
||||
}
|
||||
const closing = this._path.length >= 3 && this._samePt(pt, this._path[0]);
|
||||
// Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the
|
||||
// contour may become a nested room (a column, an inner room). Partial
|
||||
@@ -3936,18 +4029,7 @@ class HouseplanCard extends LitElement {
|
||||
const last = this._path[this._path.length - 1];
|
||||
if (this._samePt(pt, last)) return; // repeated click on the same point
|
||||
if (closing) {
|
||||
// a contour can enclose an existing room without any vertex inside it
|
||||
const clash = this._overlapRoom(this._path);
|
||||
if (clash) {
|
||||
this._showToast(this._t('toast.room_overlap', { name: clash.name || '' }));
|
||||
return; // leave the outline open so it can be corrected
|
||||
}
|
||||
this._path = [...this._path, pt];
|
||||
this._cursorPt = null;
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
this._resetRoomDialogFields();
|
||||
this._roomDialog = true;
|
||||
this._closeRoomContour();
|
||||
return;
|
||||
}
|
||||
this._path = [...this._path, pt];
|
||||
@@ -4507,7 +4589,11 @@ class HouseplanCard extends LitElement {
|
||||
this._furnPlace(this._svgPoint(ev), ev.shiftKey);
|
||||
return true;
|
||||
}
|
||||
this._decorSel = null; // select/erase on empty space clears the selection
|
||||
// Empty-space selection belongs only to Select. Erase is an object
|
||||
// command: a miss must be a true no-op. With SVG text its painted glyphs
|
||||
// do not cover the whole logical label box, so clearing here made a click
|
||||
// between letters appear to erase only the selection outline.
|
||||
if (t === 'select') this._decorSel = null;
|
||||
// …and under its own tool the picture is grabbable by its body
|
||||
// (docs/BACKDROP.md §2). Only INSIDE the image rect: press beside the
|
||||
// picture and the plane still pans with one finger.
|
||||
@@ -6425,7 +6511,7 @@ class HouseplanCard extends LitElement {
|
||||
// Bake the wall colour into the pattern — CSS vars on <pattern> content
|
||||
// do not inherit from the filled path, so var(--room-stroke) fell back
|
||||
// to grey while the wall outline used the real border colour.
|
||||
const stroke = color || 'var(--hp-muted, #607d8b)';
|
||||
const stroke = color || '#607d8b';
|
||||
return svg`<defs>
|
||||
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
|
||||
patternTransform="rotate(45) scale(${inv.toFixed(3)})">
|
||||
@@ -6452,7 +6538,7 @@ class HouseplanCard extends LitElement {
|
||||
const stage = this._stageEl;
|
||||
const v = this._viewOr(this._baseVb());
|
||||
const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1;
|
||||
const stroke = disp?.color || 'var(--hp-muted, #607d8b)';
|
||||
const stroke = disp?.color || '#607d8b';
|
||||
const solid = wallBodyNeedsSolid(united.depthUnits, px);
|
||||
const wf = this._fillColors.wall_fill;
|
||||
// Fill colour UNDER the hatch (owner 2026-08-05): both, never one instead
|
||||
@@ -7200,11 +7286,14 @@ class HouseplanCard extends LitElement {
|
||||
tapConfirm: d.marker?.tap_confirm === true,
|
||||
runFilter: '',
|
||||
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
|
||||
// Keep temporarily inactive external targets for lossless editing, but
|
||||
// discard the invalid legacy self-reference of an entity marker.
|
||||
// Keep temporarily inactive external targets for lossless editing.
|
||||
// A legacy self-reference is represented by isLight below instead of
|
||||
// remaining duplicated in the external-target list.
|
||||
controls: effectiveMarkerControls(d.marker?.binding, d.marker?.controls, d.entities),
|
||||
controlsFilter: '',
|
||||
isLight: d.marker?.is_light === true,
|
||||
isLight: d.marker?.is_light === true || hasLegacySelfLightIntent(
|
||||
d.marker?.binding, d.marker?.controls, d.entities,
|
||||
),
|
||||
useClimateTemp: d.marker?.use_climate_temp === true,
|
||||
glowRadius: Number(d.marker?.glow_radius_cm) > 0
|
||||
? String(this._imperial
|
||||
@@ -7962,7 +8051,7 @@ class HouseplanCard extends LitElement {
|
||||
label_light: d.labelLight,
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0
|
||||
? Math.min(CELL_CM_MAX, d.cellCm) : 5;
|
||||
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, d.cellCm)) : 5;
|
||||
// Nothing to clean up from here: the backend collects the superseded
|
||||
// file inside the same locked transaction that accepted this config
|
||||
// (review R3-1). A cleanup driven from the client could not be ordered
|
||||
@@ -9129,6 +9218,7 @@ class HouseplanCard extends LitElement {
|
||||
const decorMeasure = this._decorMeasure;
|
||||
const bdLive = this._bdLive;
|
||||
const furnLive = this._furnLive;
|
||||
const editorChromeMode = this._mode === 'view' ? this._editorChromeMode : this._mode;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -9143,15 +9233,7 @@ class HouseplanCard extends LitElement {
|
||||
(s) => html`<button
|
||||
data-hp="space-tab" data-id="${s.id}"
|
||||
class="tab ${this._space === s.id ? 'active' : ''}"
|
||||
@click=${() => {
|
||||
this._space = s.id;
|
||||
this._selId = null;
|
||||
this._navApplied = true;
|
||||
this._showFar = false; // the hint is per space (docs/CANVAS.md §4.1)
|
||||
this._frame = null;
|
||||
this._restoreZoom();
|
||||
this._saveNav();
|
||||
}}
|
||||
@click=${() => this._pickSpace(s.id)}
|
||||
>
|
||||
${s.title}${this._norm && this._canEdit
|
||||
? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
|
||||
@@ -9207,14 +9289,26 @@ class HouseplanCard extends LitElement {
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
|
||||
${''/* the palette lives UNDER the bar, above the stage: it is part of
|
||||
the tool, not a modal — the plan stays visible while a symbol is
|
||||
chosen, because where a sofa goes is a question about the plan */}
|
||||
${this._mode === 'decor' && this._decorTool === 'furniture' ? this._renderFurnPalette() : nothing}
|
||||
${this._canEdit && !this._kiosk
|
||||
? html`<div class="editorchrome ${this._editing ? 'open' : ''}${this._navMotion ? ' nav-' + this._navMotion : ''}" aria-hidden=${this._editing ? 'false' : 'true'}>
|
||||
<div class="editorchrome-inner ${this._navMotion ? 'nav-' + this._navMotion : ''}">
|
||||
${editorChromeMode === 'plan'
|
||||
? this._renderMarkupBar()
|
||||
: editorChromeMode === 'devices'
|
||||
? this._renderDevicesBar()
|
||||
: this._renderDecorBar()}
|
||||
${''/* the palette lives UNDER the bar, above the stage: it is part of
|
||||
the tool, not a modal — the plan stays visible while a symbol is
|
||||
chosen, because where a sofa goes is a question about the plan */}
|
||||
${editorChromeMode === 'decor' && this._decorTool === 'furniture'
|
||||
? this._renderFurnPalette()
|
||||
: nothing}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + ((this._tool === 'openwall' || this._tool === 'closewall') && this._openWallHover ? ' wallhot' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._resumeSettling && this._mode === 'view' && !this._kiosk ? ' hpresume' : ''}"
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + ((this._tool === 'openwall' || this._tool === 'closewall') && this._openWallHover ? ' wallhot' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._navMotion ? ' hpnav' : ''}${this._resumeSettling && this._mode === 'view' && !this._kiosk ? ' hpresume' : ''}"
|
||||
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a}"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
@@ -9222,7 +9316,7 @@ class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
|
||||
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
|
||||
@pointercancel=${(e: PointerEvent) => this._stagePointerCancel(e)}>
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}${this._navMotion ? ' nav-' + this._navMotion : ''}"
|
||||
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
|
||||
@@ -11716,13 +11810,13 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
<label>${this._t('space.scale_label')}</label>
|
||||
<div class="colorrow">
|
||||
<input class="namein tempin" type="number" min="0.1" max=${CELL_CM_MAX}
|
||||
<input class="namein tempin" type="number" min=${CELL_CM_MIN} max=${CELL_CM_MAX}
|
||||
step="0.1" .value=${String(d.cellCm)}
|
||||
@input=${(e: Event) => {
|
||||
const n = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._spaceDialog = {
|
||||
...d, cellCm: Number.isFinite(n) && n > 0
|
||||
? Math.min(CELL_CM_MAX, n) : d.cellCm,
|
||||
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, n)) : d.cellCm,
|
||||
};
|
||||
}} />
|
||||
<span class="opl">${this._t('space.scale_unit')}</span>
|
||||
@@ -11968,7 +12062,7 @@ class HouseplanCard extends LitElement {
|
||||
const cur = this.hass.areas[this._areaSel];
|
||||
if (cur) areas.unshift(cur);
|
||||
}
|
||||
return html`<hp-dialog .hass=${this.hass}
|
||||
return html`<hp-dialog class="roomdialog" .hass=${this.hass} wide
|
||||
.title=${edit ? this._t('room.settings_title') : this._t('room.new')}
|
||||
icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'} @hp-close=${this._roomDialogCancel}>
|
||||
<div class="body">
|
||||
@@ -12019,7 +12113,7 @@ class HouseplanCard extends LitElement {
|
||||
this._roomLabelScale,
|
||||
)}
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
<div class="row roomfooter" slot="footer">
|
||||
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
|
||||
<span class="spacer"></span>
|
||||
${edit
|
||||
|
||||
+83
-18
@@ -15,7 +15,7 @@ export class HpColorOpacity extends LitElement {
|
||||
public disabled = false;
|
||||
|
||||
private _open = false;
|
||||
private _alignEnd = false;
|
||||
private _pickerRaf = 0;
|
||||
|
||||
static properties = {
|
||||
label: { type: String },
|
||||
@@ -24,7 +24,6 @@ export class HpColorOpacity extends LitElement {
|
||||
opacity: { type: Number },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
_open: { state: true },
|
||||
_alignEnd: { state: true },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
@@ -71,24 +70,31 @@ export class HpColorOpacity extends LitElement {
|
||||
pointer-events: none;
|
||||
}
|
||||
.picker {
|
||||
position: absolute;
|
||||
z-index: 120;
|
||||
top: calc(100% + 7px);
|
||||
/* The popover attribute promotes this surface into the browser top layer, outside
|
||||
hp-dialog's scrolling/clipping body. JS supplies viewport coordinates. */
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
inset: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 226px;
|
||||
width: min(226px, calc(100vw - 16px));
|
||||
max-height: calc(100vh - 16px);
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
color: var(--primary-text-color, #fff);
|
||||
background: var(--card-background-color, #202126);
|
||||
border: 1px solid var(--primary-color, #03a9f4);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / 0.34);
|
||||
}
|
||||
.picker.end {
|
||||
right: 0;
|
||||
left: auto;
|
||||
.picker[popover]:not(:popover-open) {
|
||||
/* Keep the author-level display:grid from exposing the surface for one
|
||||
frame before showPopover() promotes it into the top layer. */
|
||||
display: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -145,36 +151,95 @@ export class HpColorOpacity extends LitElement {
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('pointerdown', this._outsidePointerDown, true);
|
||||
document.addEventListener('scroll', this._queuePickerPosition, true);
|
||||
window.addEventListener('resize', this._queuePickerPosition);
|
||||
this.addEventListener('keydown', this._keyDown, true);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
document.removeEventListener('pointerdown', this._outsidePointerDown, true);
|
||||
document.removeEventListener('scroll', this._queuePickerPosition, true);
|
||||
window.removeEventListener('resize', this._queuePickerPosition);
|
||||
this.removeEventListener('keydown', this._keyDown, true);
|
||||
if (this._pickerRaf) cancelAnimationFrame(this._pickerRaf);
|
||||
this._pickerRaf = 0;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _outsidePointerDown = (event: PointerEvent): void => {
|
||||
if (this._open && !event.composedPath().includes(this)) this._open = false;
|
||||
if (this._open && !event.composedPath().includes(this)) this._closePicker();
|
||||
};
|
||||
|
||||
private _keyDown = (event: KeyboardEvent): void => {
|
||||
if (!this._open || event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this._open = false;
|
||||
this.updateComplete.then(() => this.renderRoot.querySelector<HTMLButtonElement>('.trigger')?.focus());
|
||||
this._closePicker(true);
|
||||
};
|
||||
|
||||
private _toggle(): void {
|
||||
if (this.disabled) return;
|
||||
this._open = !this._open;
|
||||
this._alignEnd = false;
|
||||
if (this._open) {
|
||||
this._closePicker();
|
||||
return;
|
||||
}
|
||||
this._open = true;
|
||||
this.updateComplete.then(() => this._positionPicker());
|
||||
}
|
||||
|
||||
private _closePicker(refocus = false): void {
|
||||
const popup = this.renderRoot.querySelector<HTMLElement>('.picker') as any;
|
||||
if (popup?.hidePopover) {
|
||||
try {
|
||||
if (popup.matches(':popover-open')) popup.hidePopover();
|
||||
} catch {
|
||||
/* older engines simply remove the fallback surface on the next render */
|
||||
}
|
||||
}
|
||||
this._open = false;
|
||||
if (refocus) {
|
||||
this.updateComplete.then(() => this.renderRoot.querySelector<HTMLButtonElement>('.trigger')?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
private _queuePickerPosition = (): void => {
|
||||
if (!this._open) return;
|
||||
this.updateComplete.then(() => {
|
||||
const popup = this.renderRoot.querySelector<HTMLElement>('.picker');
|
||||
if (popup && popup.getBoundingClientRect().right > window.innerWidth - 8) this._alignEnd = true;
|
||||
if (this._pickerRaf) cancelAnimationFrame(this._pickerRaf);
|
||||
this._pickerRaf = requestAnimationFrame(() => {
|
||||
this._pickerRaf = 0;
|
||||
this._positionPicker();
|
||||
});
|
||||
};
|
||||
|
||||
private _positionPicker(): void {
|
||||
if (!this._open) return;
|
||||
const trigger = this.renderRoot.querySelector<HTMLElement>('.trigger');
|
||||
const popup = this.renderRoot.querySelector<HTMLElement>('.picker') as any;
|
||||
if (!trigger || !popup) return;
|
||||
popup.style.visibility = 'hidden';
|
||||
if (popup.showPopover) {
|
||||
try {
|
||||
if (!popup.matches(':popover-open')) popup.showPopover();
|
||||
} catch {
|
||||
/* fixed-position fallback remains usable without the Popover API */
|
||||
}
|
||||
}
|
||||
const anchor = trigger.getBoundingClientRect();
|
||||
const box = popup.getBoundingClientRect();
|
||||
const gap = 7;
|
||||
const edge = 8;
|
||||
const maxLeft = Math.max(edge, window.innerWidth - box.width - edge);
|
||||
let left = anchor.left;
|
||||
if (left + box.width > window.innerWidth - edge) left = anchor.right - box.width;
|
||||
left = Math.min(maxLeft, Math.max(edge, left));
|
||||
const below = anchor.bottom + gap;
|
||||
const above = anchor.top - gap - box.height;
|
||||
let top = below;
|
||||
if (below + box.height > window.innerHeight - edge && above >= edge) top = above;
|
||||
else top = Math.min(Math.max(edge, top), Math.max(edge, window.innerHeight - box.height - edge));
|
||||
popup.style.left = `${Math.round(left)}px`;
|
||||
popup.style.top = `${Math.round(top)}px`;
|
||||
popup.style.visibility = '';
|
||||
}
|
||||
|
||||
private _emit(color: string, opacity: number): void {
|
||||
@@ -202,7 +267,7 @@ export class HpColorOpacity extends LitElement {
|
||||
<span class="swatch" style=${`background:${this.color};opacity:${pct / 100}`}></span>
|
||||
</button>
|
||||
${this._open && !this.disabled ? html`
|
||||
<div class="picker ${this._alignEnd ? 'end' : ''}" role="dialog" aria-label=${this.label || 'Color'}>
|
||||
<div class="picker" popover="manual" role="dialog" aria-label=${this.label || 'Color'}>
|
||||
<div class="row">
|
||||
<span class="caption">${this.label || 'Color'}</span>
|
||||
<input type="color" .value=${this.color} aria-label=${this.label || 'Color'}
|
||||
|
||||
+3
-1
@@ -88,7 +88,7 @@
|
||||
"opening.no_entities": "No sensors bound — a static symbol on the plan.",
|
||||
"toast.opening_no_wall": "Click next to a room wall — openings sit on walls",
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · first point or Ctrl/Cmd+click — close",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
"tip.lqi": "average zigbee signal:",
|
||||
"tip.area": "area: {value}",
|
||||
@@ -157,6 +157,8 @@
|
||||
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
|
||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||
"toast.contour_min_edges": "Draw at least two edges before closing the outline",
|
||||
"toast.contour_cannot_close": "The outline cannot close because it is degenerate or intersects itself",
|
||||
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
|
||||
"toast.rooms_merged": "Rooms merged into “{name}”",
|
||||
"toast.split_pick_wall": "Start the cut on the room’s wall",
|
||||
|
||||
+3
-1
@@ -88,7 +88,7 @@
|
||||
"opening.no_entities": "Датчики не привязаны — статичный символ на плане.",
|
||||
"toast.opening_no_wall": "Кликните рядом со стеной комнаты — проёмы ставятся на стены",
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · клик по первой или Ctrl+клик — замкнуть",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
"tip.lqi": "средний сигнал zigbee:",
|
||||
"tip.area": "площадь: {value}",
|
||||
@@ -157,6 +157,8 @@
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
"toast.contour_min_edges": "Чтобы замкнуть контур, сначала нарисуйте минимум две грани",
|
||||
"toast.contour_cannot_close": "Контур нельзя замкнуть: он вырожден или пересекает сам себя",
|
||||
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
|
||||
"toast.rooms_merged": "Комнаты объединены в «{name}»",
|
||||
"toast.split_pick_wall": "Начните разрез на стене комнаты",
|
||||
|
||||
+27
-5
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { effectiveMarkerControls } from './devices';
|
||||
import { hasLegacySelfLightIntent } from './devices';
|
||||
import {
|
||||
DECOR_TEXT_BASE, decorTextScale, liveTextReference, liveTextToken, roomPoly,
|
||||
} from './logic';
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
} from './wall-thickness';
|
||||
|
||||
/** Bump when a new lossless maintenance pass is added. */
|
||||
export const PLAN_MODEL_VERSION = 3;
|
||||
export const PLAN_MODEL_VERSION = 4;
|
||||
const DEFAULT_CELL_CM = 5;
|
||||
const CELL_CM_MIN = 0.1;
|
||||
const CELL_CM_MAX = 1000;
|
||||
const DECOR_WIDTH_CM_MIN = 0.1;
|
||||
const DECOR_WIDTH_CM_MAX = 100;
|
||||
const DECOR_TEXT_CM_MIN = 0.1;
|
||||
@@ -86,7 +88,18 @@ const migrateLosslessly = (config: any): number => {
|
||||
for (const marker of config.markers || []) {
|
||||
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
|
||||
if (Array.isArray(marker.controls)) {
|
||||
const controls = effectiveMarkerControls(marker.binding, marker.controls);
|
||||
// R2: preserve the old, once-supported declaration that an entity-bound
|
||||
// switch is itself a light source before removing its redundant control.
|
||||
if (hasLegacySelfLightIntent(marker.binding, marker.controls) && marker.is_light !== true) {
|
||||
marker.is_light = true;
|
||||
n++;
|
||||
}
|
||||
// R6: maintenance knows only the direct entity binding. Do not run the
|
||||
// runtime filter here: YAML-only targets and duplicates are user data,
|
||||
// not garbage that a "lossless" optimiser may silently discard.
|
||||
const self = typeof marker.binding === 'string' && marker.binding.startsWith('entity:')
|
||||
? marker.binding.slice('entity:'.length) : '';
|
||||
const controls = self ? marker.controls.filter((eid: unknown) => eid !== self) : marker.controls;
|
||||
if (JSON.stringify(controls) !== JSON.stringify(marker.controls)) {
|
||||
marker.controls = controls.length ? controls : null;
|
||||
n++;
|
||||
@@ -114,8 +127,17 @@ const migrateLosslessly = (config: any): number => {
|
||||
}
|
||||
}
|
||||
const rawCellCm = Number(space.cell_cm);
|
||||
const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0
|
||||
? rawCellCm : DEFAULT_CELL_CM;
|
||||
if (own(space, 'cell_cm') && (
|
||||
!Number.isFinite(rawCellCm) || rawCellCm < CELL_CM_MIN || rawCellCm > CELL_CM_MAX
|
||||
)) {
|
||||
space.cell_cm = Number.isFinite(rawCellCm)
|
||||
? clamp(rawCellCm, CELL_CM_MIN, CELL_CM_MAX)
|
||||
: DEFAULT_CELL_CM;
|
||||
n++;
|
||||
}
|
||||
const repairedCellCm = Number(space.cell_cm);
|
||||
const cellCm = Number.isFinite(repairedCellCm) && repairedCellCm > 0
|
||||
? repairedCellCm : DEFAULT_CELL_CM;
|
||||
for (const shape of space.decor || []) {
|
||||
if (own(shape, 'width')) {
|
||||
const legacyWidth = Number(shape.width);
|
||||
|
||||
+4
-3
@@ -219,6 +219,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
: null;
|
||||
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
|
||||
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
|
||||
const wallStroke = disp.color || '#607d8b';
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
|
||||
@@ -226,7 +227,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
${wallUnion ? svg`<defs>
|
||||
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
|
||||
patternTransform="rotate(45)">
|
||||
<path d="M0 0 L0 8" stroke="${disp.color}" stroke-width="2"></path>
|
||||
<path d="M0 0 L0 8" stroke="${wallStroke}" stroke-width="2"></path>
|
||||
</pattern>
|
||||
</defs>` : nothing}
|
||||
${paperShapes.map((sh) =>
|
||||
@@ -243,13 +244,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
: nothing}
|
||||
${roomShapes}
|
||||
${wallUnion
|
||||
? svg`<g class="wallbodies" style="--room-stroke:${disp.color}">
|
||||
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
|
||||
<path class="wallbody-fill" d="${wallUnion.d}"
|
||||
fill="${colors.wall_fill.c}" fill-opacity="${colors.wall_fill.a}" fill-rule="evenodd"
|
||||
stroke="none" pointer-events="none"></path>
|
||||
<path class="wallbody ${solidWall ? 'solid' : ''}" data-hp="wall" data-id="union" data-kind="union"
|
||||
d="${wallUnion.d}" fill="${solidWall ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule="evenodd"
|
||||
stroke="${disp.color || 'var(--hp-muted, #607d8b)'}" stroke-width="0.6" pointer-events="none"></path>
|
||||
stroke="${wallStroke}" stroke-width="0.6" pointer-events="none"></path>
|
||||
</g>`
|
||||
: nothing}
|
||||
</svg>
|
||||
|
||||
+118
-8
@@ -178,6 +178,14 @@ export const cardStyles = css`
|
||||
touch-action: none; /* custom pinch/pan gestures */
|
||||
background: var(--ha-card-background, var(--card-background-color, #111));
|
||||
}
|
||||
/* A deliberate navigation change gets one short visual settle. The class
|
||||
is transient, so normal day/night breathing keeps its slow cadence. */
|
||||
.stage.hpnav,
|
||||
.stage.daynight.hpnav {
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
outline-color 0.18s ease;
|
||||
}
|
||||
.zoomwrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -679,20 +687,41 @@ export const cardStyles = css`
|
||||
gap: 0.25em;
|
||||
font-size: calc(1em * var(--rl-name, 1));
|
||||
}
|
||||
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
|
||||
the way the finger went and the next one arrives from the other side. */
|
||||
/* Space changes keep their direction, but travel only a few percent: the
|
||||
motion explains what changed without making the whole plan fly around. */
|
||||
@keyframes hp-slide-left {
|
||||
0% { transform: translateX(22%); opacity: 0; }
|
||||
0% { transform: translateX(4%); opacity: 0.72; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes hp-slide-right {
|
||||
0% { transform: translateX(-22%); opacity: 0; }
|
||||
0% { transform: translateX(-4%); opacity: 0.72; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
@keyframes hp-editor-enter {
|
||||
0% { transform: translateY(5px) scale(0.997); opacity: 0.72; }
|
||||
100% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes hp-editor-exit {
|
||||
0% { transform: translateY(-4px) scale(1.002); opacity: 0.72; }
|
||||
100% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes hp-editor-swap {
|
||||
0% { transform: scale(0.997); opacity: 0.68; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.zoomwrap.slide-left { animation: hp-slide-left 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.zoomwrap.slide-right { animation: hp-slide-right 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.zoomwrap.nav-enter { animation: hp-editor-enter 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.zoomwrap.nav-exit { animation: hp-editor-exit 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.zoomwrap.nav-swap { animation: hp-editor-swap 0.16s ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
|
||||
.stage.hpnav,
|
||||
.stage.daynight.hpnav { transition: none; }
|
||||
.zoomwrap.slide-left,
|
||||
.zoomwrap.slide-right,
|
||||
.zoomwrap.nav-enter,
|
||||
.zoomwrap.nav-exit,
|
||||
.zoomwrap.nav-swap { animation: none; }
|
||||
}
|
||||
/* The name is the anchor: the label box is centred on the room point, so
|
||||
anything that takes part in its layout SHIFTS THE NAME. The gear button
|
||||
@@ -951,8 +980,15 @@ export const cardStyles = css`
|
||||
new label on top of the old one. Only labels: a line or a rectangle
|
||||
under the text tool stays inert, so the press reaches the stage and a
|
||||
new label is created there. */
|
||||
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext,
|
||||
.stage.mode-decor.dtool-select .decorlayer .dshape.dtext,
|
||||
.stage.mode-decor.dtool-erase .decorlayer .dshape.dtext {
|
||||
/* A label is one logical decor object. SVG's visiblePainted would hit
|
||||
only the ink of individual glyphs, so spaces, counters and the area
|
||||
inside its selection glow behaved like empty canvas. */
|
||||
pointer-events: bounding-box;
|
||||
}
|
||||
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
|
||||
pointer-events: visiblePainted;
|
||||
cursor: text;
|
||||
}
|
||||
.decorlayer .dsel {
|
||||
@@ -1301,7 +1337,9 @@ export const cardStyles = css`
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 0.14s ease, color 0.14s ease, transform 0.14s ease;
|
||||
}
|
||||
.modetab:active { transform: scale(0.97); }
|
||||
.modetab ha-icon { --mdc-icon-size: 15px; }
|
||||
.modetab .closex {
|
||||
--mdc-icon-size: 13px;
|
||||
@@ -2331,6 +2369,37 @@ export const cardStyles = css`
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Room settings contains long radio labels, two source pickers and live
|
||||
previews, so it deliberately uses hp-dialog's medium width. Keep its
|
||||
content shrinkable as well: the generic srcrow rule is nowrap because
|
||||
compact switch rows need it, but here that created a horizontal scroll
|
||||
box even on a wide desktop viewport. */
|
||||
hp-dialog.roomdialog .body {
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
hp-dialog.roomdialog .body > * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
hp-dialog.roomdialog .srcrow {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
hp-dialog.roomdialog .srcrow > span:first-of-type {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.35;
|
||||
}
|
||||
hp-dialog.roomdialog .dropbtn > b,
|
||||
hp-dialog.roomdialog .dropbtn > .ref {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
hp-dialog .row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -2343,6 +2412,47 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
hp-dialog .row.roomfooter {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Keep the last editor bar mounted while the row collapses. This makes
|
||||
both entering and leaving an editor change the card geometry gradually;
|
||||
the header ResizeObserver keeps the stage fitted throughout. */
|
||||
.editorchrome {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
grid-template-rows 0.18s cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
opacity 0.12s ease,
|
||||
visibility 0s linear 0.18s;
|
||||
}
|
||||
.editorchrome.open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
overflow: visible;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
.editorchrome.open.nav-enter {
|
||||
overflow: hidden;
|
||||
}
|
||||
.editorchrome-inner {
|
||||
min-height: 0;
|
||||
}
|
||||
.editorchrome-inner.nav-enter { animation: hp-editor-enter 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.editorchrome-inner.nav-exit { animation: hp-editor-exit 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.editorchrome-inner.nav-swap { animation: hp-editor-swap 0.16s ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modetab { transition: none; }
|
||||
.editorchrome { transition: none; }
|
||||
.editorchrome-inner.nav-enter,
|
||||
.editorchrome-inner.nav-exit,
|
||||
.editorchrome-inner.nav-swap { animation: none; }
|
||||
}
|
||||
/* Device info can have Edit + Open in HA + Close. A small HA dialog is
|
||||
narrower than those three Russian-labelled actions; without wrapping,
|
||||
HA's end-aligned footer pushed the first button outside the surface. */
|
||||
|
||||
Reference in New Issue
Block a user