!showHidden && !!markers?.some((m) => m.id === id && m.hidden === true);
+ return leds.size > 0 && devices.some((d) => d.space === space?.id && leds.has(d.id)
+ && (!d.hidden || showHidden) && !hidden(d.id));
+}
+
/** The point at half the polyline length (ТЗ §5), in render units (`scale` = NORM_W). */
export function ledAnchor(points: readonly number[][], scale: number): { x: number; y: number } {
const step = (i: number) => Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);
diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts
index 5c35fcca..37669d3f 100755
--- a/src/led-strip-runtime.ts
+++ b/src/led-strip-runtime.ts
@@ -347,6 +347,19 @@ export interface LedFrame {
polygons: readonly LightRoomPolygon[];
}
+/**
+ * The room of a strip (ТЗ §5, r1 M2): an explicit valid `room_id` of its
+ * marker wins; otherwise the room containing the half-length anchor. A stale
+ * `room_id` (no such room in the space) falls back to the geometry.
+ */
+export function stripRoom(
+ rooms: readonly RoomCfg[], roomId: string | null | undefined, anchor: number[] | null,
+ inRoom: (point: number[], room: RoomCfg) => boolean,
+): RoomCfg | undefined {
+ return (roomId ? rooms.find((r) => r.id === roomId) : undefined)
+ ?? (anchor ? rooms.find((r) => inRoom(anchor, r)) : undefined);
+}
+
export function ledFrame(input: LedFrameInput): LedFrame {
const byId = new Map(input.devices.map((device) => [device.id, device]));
const radius = (LED_DEFAULT_RADIUS_CM / input.cellCm) * input.gridPitch;
@@ -373,8 +386,7 @@ export function ledFrame(input: LedFrameInput): LedFrame {
const device = byId.get(strip.marker as string);
if (!device || device.space !== input.space.id) continue;
if (device.hidden && !input.showHidden) continue;
- const anchor = stripAnchor(pts(strip.points));
- const room = anchor ? input.space.rooms.find((r) => input.inRoom(anchor, r)) : undefined;
+ const room = stripRoom(input.space.rooms, device.marker?.room_id, stripAnchor(pts(strip.points)), input.inRoom);
views.push(ledStripView({
strip,
device,
diff --git a/src/space-render.ts b/src/space-render.ts
index 866e7404..086a6e32 100644
--- a/src/space-render.ts
+++ b/src/space-render.ts
@@ -44,7 +44,7 @@ import {
type PresentationActivityRuntime, type ResolvedDevicePresentation,
} from './device-presentation';
import { presentationSnapshotKey } from './render-device-snapshot';
-import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
+import { deviceFaceStyle, deviceThemeClass, renderDeviceFace, renderDeviceShadowFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
@@ -59,7 +59,7 @@ import {
type Layout, type ContentItem, type SpaceCardFit,
} from './space-geometry';
import { resolveZeroWalls } from './zero-walls';
-import { ledRuntime, ledStripsByMarker } from './led-strip-gate';
+import { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
@@ -602,12 +602,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: null);
}
const planLightSources = resolvedLightSources(planHass, devs, null, o.virtualLights);
- const markers = iconDevs.map((d) => {
- const p = markerPos(d, o.layout, o.cfg, defPos, space, areaRelocationIds);
- const left = ((p.x - vb[0]) / vb[2]) * 100;
- const top = ((p.y - vb[1]) / vb[3]) * 100;
- const showLqi = disp.showLqi ?? (o.showSignal !== false);
- const presentation = o.presentations?.get(presentationSnapshotKey(d.id, showLqi))
+ const showLqi = disp.showLqi ?? (o.showSignal !== false);
+ const presentationOf = (d: DevItem) => o.presentations?.get(presentationSnapshotKey(d.id, showLqi))
|| resolveDevicePresentation(planHass, d, {
liveStates: o.liveStates !== false,
showTemperature: o.showTemperature !== false,
@@ -619,6 +615,18 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
registryHass,
reducedMotion: o.reducedMotion,
});
+ // #780 r1 M1: a strip keeps its value badge at the half-length anchor, passive.
+ const ledBadges = leds.size ? devs.filter((d) => leds.has(d.id)).map((d) => {
+ const face = renderDeviceShadowFace(presentationOf(d));
+ const a = ledAnchor(leds.get(d.id)!.points, NORM_W);
+ return face === nothing ? nothing : html`${face}
`;
+ }) : nothing;
+ const markers = iconDevs.map((d) => {
+ const p = markerPos(d, o.layout, o.cfg, defPos, space, areaRelocationIds);
+ const left = ((p.x - vb[0]) / vb[2]) * 100;
+ const top = ((p.y - vb[1]) / vb[3]) * 100;
+ const presentation = presentationOf(d);
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
const a11yState = deviceA11yState(presentation);
const deviceAriaLabel = deviceAccessibleLabel([
@@ -993,7 +1001,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
${decorImages}
${stairShapes}
${glowPools}
- ${leds.size ? ledRuntime(space.id, () => o.moonHost?.requestUpdate())?.renderStaticLed({
+ ${ledVisible(space, devs, false, o.cfg.markers) ? ledRuntime(space.id, () => o.moonHost?.requestUpdate())?.renderStaticLed({
space, devices: spaceDevs, hass: planHass, virtualLights: o.virtualLights,
defaultColor: colors.glow_light.c, paletteAlpha: colors.glow_light.a, cellCm,
gridPitch: GRID_PITCH, iconPct, glowFor: (room) => !!o.lightPools && roomGlowOf(disp.glow, room),
@@ -1033,7 +1041,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
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. */}
- ${markers}${labels}
+ ${markers}${ledBadges}${labels}
`;
}
diff --git a/src/styles/devices.styles.ts b/src/styles/devices.styles.ts
index eb93c293..38efb408 100644
--- a/src/styles/devices.styles.ts
+++ b/src/styles/devices.styles.ts
@@ -253,6 +253,8 @@ export const devicesStyles = css`
transform: translateX(-50%);
flex-direction: column-reverse;
}
+ .dev.led-badge { pointer-events: none; }
+ .dev.led-badge .device-core { visibility: hidden; }
.device-core {
position: relative;
z-index: 1;
diff --git a/test/led-strip-editor.test.mjs b/test/led-strip-editor.test.mjs
index a6c9d15c..ab748452 100644
--- a/test/led-strip-editor.test.mjs
+++ b/test/led-strip-editor.test.mjs
@@ -270,3 +270,26 @@ test('ТЗ §4 п.9: the card’s LED history branch — stale clears, failure r
assert.deepEqual(calls.splice(0), ['history.redone']);
assert.equal(card._devicePositionBusy, false);
});
+
+test('r1 M3: a space switch finishes the unfinished chain in its own space, never in the one shown now', async () => {
+ const { led, host, saves } = setup();
+ const shown = host._spaceModel();
+ led.layer(shown);
+ led.open();
+ click(led, 100, 100);
+ click(led, 300, 100);
+ click(led, 300, 300);
+ const other = { ...shown, id: 'other' };
+ host._spaceModel = () => other;
+ host._space = 'other';
+ led.layer(other); // the card renders the new space: the session resets
+ await settle();
+ await settle();
+ assert.equal(saves.length, 1, 'one write');
+ assert.equal(stripsOf(host, 's').length, 1, 'the chain is stored in the space it was drawn in');
+ assert.deepEqual(stripsOf(host, 'other'), [], 'nothing is written into the space shown now');
+ assert.equal(stripsOf(host, 's')[0].points.length, 3);
+ assert.equal(led.tool, false);
+ assert.equal(led.sel, null, 'no selection carried into the other space');
+ assert.equal(led.picker, null, 'no device picker over the other space');
+});
diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs
index 308cf497..39b3e0f0 100644
--- a/test/led-strip-runtime.test.mjs
+++ b/test/led-strip-runtime.test.mjs
@@ -3,7 +3,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ledAnchor, ledStripsByMarker } from '../test-build/led-strip-gate.js';
-import { faceContext, ledFrame, ledStripView } from '../test-build/led-strip-runtime.js';
+import { faceContext, ledFrame, ledStripView, stripRoom } from '../test-build/led-strip-runtime.js';
import { LedFieldCache, buildFieldGeometry, falloffAt } from '../test-build/led-strip-field.js';
import { GLOW_FALLOFF } from '../test-build/glow-scene.js';
import { stripAnchor } from '../test-build/led-strip-geometry.js';
@@ -136,3 +136,25 @@ test('AC9: the frame gives every strip the 50 cm default, not the shared radius;
assert.equal(frame.views.length, 1, 'the unbound strip is not a View strip');
assert.ok(Math.abs(frame.views[0].radius - (50 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
});
+
+test('AC2/r1 M2: an explicit valid room_id wins over the anchor room; a stale one falls back', () => {
+ const rooms = [{ id: 'A' }, { id: 'B' }];
+ const inA = (point, room) => room.id === 'A' && point[0] < 500;
+ const frameWith = (roomId) => ledFrame({
+ space: { id: 's', vb: [0, 0, 1000, 1000], rooms, led_strips: [
+ // Anchor (half length) at x = 250: geometrically inside A.
+ { id: 'a', points: [[0.1, 0.1], [0.4, 0.1]], marker: 'm1' },
+ ] },
+ devices: [{ id: 'm1', name: 'Lamp', primary: 'light.led', entities: ['light.led'], space: 's',
+ marker: { id: 'm1', binding: 'device:m1', ...(roomId === undefined ? {} : { room_id: roomId }) } }],
+ hass: { states: { 'light.led': { state: 'on', attributes: {} } } },
+ defaultColor: '#ffd27b', paletteAlpha: 0.7, cellCm: 5, gridPitch: 1000 / 240, iconPct: 3.4,
+ scene: null, polygons: [], glowFor: (room) => room.id === 'B', inRoom: inA, showHidden: false,
+ });
+ assert.equal(frameWith(undefined).views[0].glow, false, 'no room_id: the anchor room A decides (Glow off)');
+ assert.equal(frameWith('B').views[0].glow, true, 'explicit room_id B wins over the geometric A');
+ assert.equal(frameWith('Z').views[0].glow, false, 'a stale room_id falls back to the geometry');
+ assert.equal(stripRoom(rooms, 'B', [250, 100], inA)?.id, 'B');
+ assert.equal(stripRoom(rooms, null, [250, 100], inA)?.id, 'A');
+ assert.equal(stripRoom(rooms, null, null, inA), undefined);
+});