fix(led): polish glow and interactions (#784)

Issue: #784
User-Visible: yes
This commit is contained in:
Matysh
2026-10-03 10:47:28 +03:00
parent 4962de1d5c
commit 706eb02d70
21 changed files with 243 additions and 72 deletions
+4 -2
View File
@@ -783,8 +783,10 @@ export class LedStripEditor {
.sort((a, b) => Number(isLight(b)) - Number(isLight(a)) || a.name.localeCompare(b.name));
const close = () => { this.picker = null; this.host.requestUpdate(); };
return html`<hp-dialog .hass=${this.host.hass} data-kind="led-picker" .title=${this.t('led.pick_title')}
icon="mdi:led-strip-variant" @hp-close=${close}>
<div class="body led-picker" style="display:flex;flex-direction:column;gap:4px">
icon="mdi:led-strip-variant" flex-content @wheel=${(e: WheelEvent) => e.stopPropagation()}
@hp-close=${close}>
<div class="body led-picker"
style="display:flex;flex-direction:column;gap:4px;min-height:0;max-height:min(60dvh,520px);overflow-y:auto;overscroll-behavior:contain">
${devices.length ? devices.map((d) => {
const owner = ownerOf(this.host._serverCfg, d.id);
const taken = !!owner && owner.strip.id !== stripId;
+97 -35
View File
@@ -5,19 +5,19 @@
* card has a light scene — the static card with `light_pools: false` never
* loads it (ТЗ §13.1).
*
* The field is the exact distance field of the strip with the shared falloff:
* every piece of the strip paints opaque grey bands of a luminance mask (round
* caps and joins, so one piece never doubles itself), pieces meet through
* `mix-blend-mode: lighten` — the maximum, i.e. the nearest piece — and each
* piece is clipped to the floor its own emitters can see. A hidden part never
* lights through another part's visibility, a closed strip has no seam and a
* corner no double brightness.
* The field is the distance field of one continuous strip path with the shared
* falloff. Visibility remains sampled in bounded pieces, but their visible
* regions are unioned into one clip before the continuous path is painted.
* That separation keeps walls opaque without exposing piece boundaries in the
* gradient at straight cuts or corners.
*/
import { noChange, nothing, svg, type TemplateResult } from 'lit';
import { Directive, directive, type PartInfo } from 'lit/directive.js';
import { repeat } from 'lit/directives/repeat.js';
import {
GLOW_FALLOFF, type LightBarrierScene, type LightRoomPolygon,
createGlowRuntimeState, disposeGlowRuntime, forgetGlowSpace, GLOW_FALLOFF,
pruneGlowSources, transitionGlowSource, type GlowRuntimeHost,
type GlowRuntimeState, type LightBarrierScene, type LightRoomPolygon,
} from './glow-scene';
import { visibilityPolygon } from './light-visibility';
import {
@@ -45,11 +45,11 @@ const memo = directive(Memo);
export const LED_FIELD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
/**
* Bands of the luminance field. Twelve keep the midpoint error at the r/2
* visual acceptance point below 10%, without multiplying every visibility
* piece into sixteen SVG paint nodes.
* Bands of the luminance field. The field is painted once per strip rather
* than once per visibility piece, so 48 steps remain bounded while keeping
* neighbouring alpha levels below the threshold that showed as rings.
*/
export const LED_FIELD_BANDS = 12;
export const LED_FIELD_BANDS = 48;
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
@@ -60,6 +60,8 @@ interface FieldPiece {
}
interface FieldGeometry {
/** One continuous path; never split at visibility/cache boundaries. */
d: string;
pieces: FieldPiece[];
box: { x: number; y: number; w: number; h: number };
}
@@ -184,6 +186,9 @@ export function buildFieldGeometry(input: {
const path = compactPoints(pts(input.points));
if (path.length < 2 || !(input.radius > 0)) return null;
const r = input.radius;
const closed = isClosedStrip(path);
const visiblePath = closed ? path.slice(0, -1) : path;
const d = `${visiblePath.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')}${closed ? ' Z' : ''}`;
// Consecutive pieces of at most r along the polyline.
const runs: Pt[][] = [];
let run: Pt[] = [path[0]];
@@ -218,14 +223,14 @@ export function buildFieldGeometry(input: {
let clip: string[] | null;
try { clip = fans(emitters, r, input.scene); } catch { continue; } // fail-dark for this piece
if (clip && !clip.length) continue;
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' '), clip });
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' '), clip });
for (const p of piece) {
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
}
}
if (!pieces.length) return null;
return { pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
return { d, pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
}
/** The shared falloff at a relative distance 0…1 (GLOW_FALLOFF, linear between stops). */
@@ -252,13 +257,61 @@ export interface LedFieldInput {
spaceId: string;
/** The card that owns the bounded cache (one per card, gone with it). */
owner: object;
requestUpdate: () => void;
isConnected: () => boolean;
reducedMotion?: () => boolean;
}
const fieldCaches = new WeakMap<object, LedFieldCache>();
interface FieldLifecycle {
state: GlowRuntimeState;
host: GlowRuntimeHost;
callbacks: Pick<LedFieldInput, 'requestUpdate' | 'isConnected' | 'reducedMotion'>;
spaceId: string;
}
const fieldLifecycles = new WeakMap<object, FieldLifecycle>();
function fieldLifecycle(input: LedFieldInput): FieldLifecycle {
let lifecycle = fieldLifecycles.get(input.owner);
if (!lifecycle) {
const callbacks = {
requestUpdate: input.requestUpdate,
isConnected: input.isConnected,
reducedMotion: input.reducedMotion,
};
const host: GlowRuntimeHost = {
window: () => window,
isConnected: () => callbacks.isConnected(),
requestUpdate: () => callbacks.requestUpdate(),
reducedMotion: () => callbacks.reducedMotion?.()
?? window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
?? false,
};
lifecycle = { state: createGlowRuntimeState(), host, callbacks, spaceId: '' };
fieldLifecycles.set(input.owner, lifecycle);
} else {
lifecycle.callbacks.requestUpdate = input.requestUpdate;
lifecycle.callbacks.isConnected = input.isConnected;
lifecycle.callbacks.reducedMotion = input.reducedMotion;
}
if (lifecycle.spaceId && lifecycle.spaceId !== input.spaceId) {
forgetGlowSpace(lifecycle.state, lifecycle.host, lifecycle.spaceId);
}
lifecycle.spaceId = input.spaceId;
return lifecycle;
}
/** Disconnect (ТЗ §13.2): no retained entry of this owner. */
export function releaseLedField(owner: object): void {
fieldCaches.get(owner)?.clear();
fieldCaches.delete(owner);
const lifecycle = fieldLifecycles.get(owner);
if (lifecycle) disposeGlowRuntime(lifecycle.state, lifecycle.host);
fieldLifecycles.delete(owner);
}
/** Keep rendering while an off transition still owns a DOM node. */
export function hasLedField(owner: object): boolean {
return !!fieldLifecycles.get(owner)?.state.renderedSources.size;
}
/** The performance witness: what this owner retains right now. */
export function ledFieldStats(owner: object): { visibility: number; sources: number; recomputes: number } {
@@ -284,9 +337,18 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
if (!input.scene) return svg`` as unknown as TemplateResult;
const cache = ledFieldCache(input.owner);
cache.forSpace(input.spaceId);
const lifecycle = fieldLifecycle(input);
const scene = input.scene;
const seen = new Set<string>();
const fields = input.views.flatMap((view) => {
if (!view.glow || view.state === 'unavailable') return [];
const lifecycleKey = `${input.spaceId}|${view.strip.id}`;
seen.add(lifecycleKey);
const active = view.glow && view.state === 'on' && !!view.appearance;
const transition = transitionGlowSource(lifecycle.state, lifecycle.host, lifecycleKey, active);
if (!transition) return [];
if (active && view.appearance) lifecycle.state.lastAppearance.set(lifecycleKey, view.appearance);
const appearance = active ? view.appearance : lifecycle.state.lastAppearance.get(lifecycleKey) ?? null;
if (!appearance) return [];
const key = `${view.strip.id}|${pointsKey(view.strip.points)}|${view.radius.toFixed(5)}|${scene.fingerprint}`;
const geometry = cache.read(key, () => buildFieldGeometry({
points: view.strip.points,
@@ -296,8 +358,9 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
faces: input.faces,
spaceId: input.spaceId,
}));
return geometry ? [{ view, geometry }] : [];
return geometry ? [{ view: { ...view, appearance }, geometry, transition }] : [];
});
pruneGlowSources(lifecycle.state, lifecycle.host, input.spaceId, seen);
if (!fields.length) return svg`` as unknown as TemplateResult;
const bands = Array.from({ length: LED_FIELD_BANDS }, (_, k) => {
const outer = 1 - k / LED_FIELD_BANDS;
@@ -309,34 +372,33 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
data-led-cache="${cache.size}" data-led-recomputes="${cache.recomputes}">
<defs><clipPath id="hp-led-floor">${fieldFloor(scene).map((d) => svg`<path d="${d}"></path>`)}</clipPath></defs>
<g clip-path="url(#hp-led-floor)">
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry }) => {
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry, transition }) => {
const id = `${cache.id(view.strip.id)}`;
const r = view.radius;
const on = view.state === 'on' && !!view.appearance;
const box = geometry.box;
const closed = isClosedStrip(pts(view.strip.points));
const clipped = geometry.pieces.flatMap((piece, k): Array<FieldPiece & { clip: string[]; clipId: number }> => piece.clip
? [{ ...piece, clip: piece.clip, clipId: k }] : []);
const free = geometry.pieces.filter((piece) => !piece.clip).map((piece) => piece.d).join(' ');
const paint = [...clipped, ...(free ? [{ d: free, clip: null, clipId: -1 }] : [])];
return memo([geometry, on, view.appearance?.c, view.appearance?.alpha, r, id], () => svg`<g class="glow-spot led-field ${on ? '' : 'is-leaving'}" data-led-field="${view.strip.id}"
data-pieces="${geometry.pieces.length}" data-closed="${closed ? 'true' : 'false'}">
const clipped = geometry.pieces.filter((piece): piece is FieldPiece & { clip: string[] } => !!piece.clip);
const free = geometry.pieces.filter((piece) => !piece.clip);
const visibilityClip = clipped.length > 0;
return memo([geometry, transition.entering, transition.leaving, view.appearance.c,
view.appearance.alpha, r, id], () => svg`<g
class="glow-spot led-field ${transition.entering ? 'is-entering' : ''} ${transition.leaving ? 'is-leaving' : ''}"
data-led-field="${view.strip.id}" data-led-phase="${transition.entering ? 'entering' : transition.leaving ? 'leaving' : 'visible'}"
data-pieces="${geometry.pieces.length}" data-bands="${LED_FIELD_BANDS}"
data-closed="${closed ? 'true' : 'false'}">
<defs>
${clipped.map((piece) => svg`<clipPath id="hp-led-clip-${id}-${piece.clipId}">
${''/* Subpaths of one path have the same union semantics in a clipPath,
without one DOM node per emitter. */}
<path d="${piece.clip.join(' ')}"></path>
</clipPath>`)}
${visibilityClip ? svg`<clipPath id="hp-led-visible-${id}">
${free.map((piece) => svg`<path d="${piece.d}" fill="none" stroke="white"
stroke-width="${2 * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
${clipped.map((piece) => svg`<path d="${piece.clip.join(' ')}"></path>`)}
</clipPath>` : nothing}
<mask id="hp-led-mask-${id}" maskUnits="userSpaceOnUse"
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
color-interpolation="sRGB" style="mask-type:luminance">
<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="black"></rect>
<g style="isolation:isolate">
${paint.map((piece) => svg`<g style="mix-blend-mode:lighten"
clip-path=${piece.clip ? `url(#hp-led-clip-${id}-${piece.clipId})` : nothing}>
${bands.map((band) => svg`<path d="${piece.d}" fill="none" stroke="${grey(band.value)}"
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
</g>`)}
<g clip-path=${visibilityClip ? `url(#hp-led-visible-${id})` : nothing}>
${bands.map((band) => svg`<path d="${geometry.d}" fill="none" stroke="${grey(band.value)}"
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
</g>
</mask>
</defs>
+2 -2
View File
@@ -25,8 +25,8 @@ export const LED_MAX_STRIPS = 50;
export const LED_MAX_POINTS = 50;
/** Geometric tolerance for "lies on a face": 0.001 cm, never a screen magnet. */
export const LED_EPSILON_CM = 0.001;
/** Default linear field radius (ТЗ §3): 50 cm, independent of the shared one. */
export const LED_DEFAULT_RADIUS_CM = 50;
/** Default linear field radius (#784): 30 cm, independent of the shared one. */
export const LED_DEFAULT_RADIUS_CM = 30;
/** Total stripe thickness in base device diameters (ТЗ §3). */
export const LED_THICKNESS_OFF_D = 0.08;
export const LED_THICKNESS_ON_D = 0.12;
+11 -11
View File
@@ -6,13 +6,9 @@
* and the hit path; the card keeps the device model, the actions and the
* light state, and passes them in. Nothing here writes configuration.
*
* The linear field is the exact distance field of the strip with the shared
* falloff: every piece of the strip paints opaque grey bands of a luminance
* mask (round caps and joins, so one piece never doubles itself), pieces meet
* through `mix-blend-mode: lighten` — the maximum, i.e. the nearest piece —
* and each piece is clipped to the floor its own emitters can see. A hidden
* part therefore never lights through another part's visibility (ТЗ §6), a
* closed strip has no seam and a corner no double brightness (ТЗ §3).
* The linear field paints one continuous path through a union of bounded
* visibility regions. Cache boundaries therefore never become visible seams,
* while the same wall/door scene still clips the light.
*/
import { nothing, svg, type TemplateResult } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
@@ -81,7 +77,7 @@ export interface LedStripView {
glow: boolean;
/** Resolved light colour and per-stop alpha when on; null otherwise. */
appearance: { c: string; alpha: number } | null;
/** Field radius, plan units: the marker's own radius or 50 cm. */
/** Field radius, plan units: the marker's own radius or 30 cm. */
radius: number;
}
@@ -261,7 +257,7 @@ export function renderLedStripes(input: LedStripeInput): TemplateResult {
<feGaussianBlur stdDeviation="${iso.shadow.sigma}"></feGaussianBlur></filter></defs>` : nothing}
<style>
.led-strip .led-focus { fill: none; stroke: transparent; }
.led-strip:focus-within .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
</style>
${repeat(ordered, (view) => view.strip.id, (view) => {
@@ -541,7 +537,8 @@ export function renderLedLayerFor(
export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): TemplateResult {
if (host.isConnected === false) return svg`` as unknown as TemplateResult;
const frame = ledFrameFor(host, space, spaceGlow);
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
const module = frame.scene && (fieldWanted(frame.views) || field?.hasLedField(host))
? field || ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
if (!module) return svg`` as unknown as TemplateResult;
return module.renderLedField({
views: frame.views,
@@ -550,6 +547,8 @@ export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlo
faces: frame.faces,
spaceId: space.id,
owner: host,
requestUpdate: () => host.requestUpdate(),
isConnected: () => host.isConnected !== false,
});
}
@@ -611,9 +610,10 @@ export function renderStaticLed(input: StaticLedInput): TemplateResult {
occluders: [], floor: [], fingerprint: '',
masonryGeometry: input.bodies.masonryGeometry, opaqueBodies: input.bodies.opaqueBodies,
}, (LED_EPSILON_CM / input.cellCm) * input.gridPitch);
const module = input.scene && input.live && fieldWanted(views)
const module = input.scene && input.live && (fieldWanted(views) || field?.hasLedField(input.owner))
? ledField(input.space.id, input.ready) : null;
return svg`${module ? module.renderLedField({
views, scene: input.scene as LightBarrierScene, polygons, faces, spaceId: input.space.id, owner: input.owner,
requestUpdate: input.ready, isConnected: () => true,
}) : nothing}${renderLedStripes({ views, d: frame.d, faces, perUnit: input.perUnit, handlers: null })}` as unknown as TemplateResult;
}
+1 -1
View File
@@ -209,7 +209,7 @@ function extrasCss(options: FormKitCssOptions): string {
${form} [aria-invalid="true"] { border-color: var(${tokens.danger}) !important; }
.${p}-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 7px; background: var(${tokens.surface}); }
.${p}-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
.${p}-unit > input:focus { outline: 0; }
${form} .${p}-unit > input:focus { outline: 0; }
.${p}-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(${tokens.muted}); font-size: .875rem; }
.${p}-unit:focus-within { outline: 2px solid var(${tokens.accent}); outline-offset: 2px; }
.${p}-unit.${p}-unit-wide, .${p}-unit.${p}-unit-wide > input { width: 100%; min-width: 0; }