mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
181 lines
7.9 KiB
TypeScript
181 lines
7.9 KiB
TypeScript
import { union, type Geom } from 'polyclip-ts';
|
|
import { formatArea } from './area-format';
|
|
import { removedPlanBindings } from './devices';
|
|
import { geometryArea, floorMinusBodies } from './physical-geometry';
|
|
import { prepareSpacePhysicalGeometryInputs } from './plan-geometry-preflight';
|
|
import { GRID_PITCH, GRID_STEP_N, NORM_W } from './space-geometry';
|
|
import { hassValue, roomPoly, valueWithUnit } from './logic';
|
|
import { innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry } from './wall-thickness';
|
|
import type { Marker, ServerConfig, SpaceModel, SummaryPanelSource } from './types';
|
|
import type { HaRegistrySnapshot } from './ha-binding-status';
|
|
import type { SummaryHass } from './summary-panel-host';
|
|
import { geometryMinusStairsSteps } from './stairs';
|
|
|
|
/** Count the unique real HA devices represented anywhere on the plan. */
|
|
export function representedHaDeviceIds(input: {
|
|
registry: HaRegistrySnapshot;
|
|
areaToSpace: Record<string, string>;
|
|
spaceIds: ReadonlySet<string>;
|
|
firstSpaceId: string;
|
|
markers: readonly Marker[];
|
|
}): Set<string> | null {
|
|
if (!input.registry.authoritative) return null;
|
|
const out = new Set<string>();
|
|
const removed = removedPlanBindings(input.markers);
|
|
for (const device of Object.values(input.registry.devices || {})) {
|
|
if (!device?.id || device.entry_type === 'service' || removed.devices.has(device.id)) continue;
|
|
if (device.area_id && input.areaToSpace[device.area_id]) out.add(device.id);
|
|
}
|
|
for (const marker of input.markers || []) {
|
|
if (marker.removed || marker.binding === 'virtual') continue;
|
|
const separator = String(marker.binding || '').indexOf(':');
|
|
if (separator < 1) continue;
|
|
const kind = marker.binding.slice(0, separator);
|
|
const ref = marker.binding.slice(separator + 1);
|
|
const device = kind === 'device' ? input.registry.devices?.[ref] : null;
|
|
const entity = kind === 'entity' ? input.registry.entities?.[ref] : null;
|
|
const deviceId = kind === 'device' ? ref : entity?.device_id;
|
|
const registryArea = kind === 'device' ? device?.area_id
|
|
: entity?.area_id || (deviceId && input.registry.devices?.[deviceId]?.area_id);
|
|
const manualRoomWithoutArea = typeof marker.room_id === 'string'
|
|
&& marker.room_id.length > 0 && marker.area === null;
|
|
const area = manualRoomWithoutArea ? '' : marker.area || registryArea || '';
|
|
const space = area && input.areaToSpace[area] || marker.space || input.firstSpaceId;
|
|
if (deviceId && input.registry.devices?.[deviceId]
|
|
&& input.spaceIds.has(space)
|
|
&& (!removed.devices.has(deviceId) || kind === 'entity' && removed.liveEntities.has(ref))) {
|
|
out.add(deviceId);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function unionGeometry(current: Geom | null, next: Geom): Geom {
|
|
if (!current?.length) return next;
|
|
if (!next?.length) return current;
|
|
return union(current, next);
|
|
}
|
|
|
|
/**
|
|
* Wall masonry and junction topology of one space, computed once (#509).
|
|
*
|
|
* `innerContourForRoom` accepts both as optional arguments and, without them,
|
|
* unions the whole space's masonry again for EVERY room: on the large-house
|
|
* fixture that was 176 ms per room and 11 s for the panel's first frame. The
|
|
* card has always passed them from its own render cache (`_innerContour`);
|
|
* the panel now does the same, one pass per space instead of one per room.
|
|
*/
|
|
export function spaceWallGeometry(
|
|
space: SpaceModel,
|
|
prepared: ReturnType<typeof prepareSpacePhysicalGeometryInputs>,
|
|
): { roomGeom: unknown; multiWallNodes: ReturnType<typeof multiWallNodesForGeometry> } {
|
|
const united = wallBodiesGeometry(
|
|
space.rooms, prepared.walls, prepared.openCuts, [],
|
|
GRID_STEP_N, prepared.cellCm, GRID_PITCH, NORM_W,
|
|
);
|
|
return {
|
|
roomGeom: united.status === 'ok' || united.status === 'degraded-extra' ? united.roomGeom : undefined,
|
|
multiWallNodes: multiWallNodesForGeometry(
|
|
space.rooms, prepared.walls, prepared.openCuts,
|
|
GRID_STEP_N, prepared.cellCm, GRID_PITCH, NORM_W,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Тот же расчёт, но шагами по комнате (#509).
|
|
*
|
|
* Даже с общей кладкой пространства полный обход большого дома — это ~1,5 с
|
|
* непрерывной работы в главном потоке. Вне кадра он не задерживает первый
|
|
* показ панели, но всё ещё «подвешивает» интерфейс на всё это время, а это
|
|
* ровно тот симптом, ради которого заведена задача. Поэтому расчёт отдаёт
|
|
* управление после каждой комнаты: вызывающий сам решает, сколько работы
|
|
* уместить в кадр.
|
|
*/
|
|
export function* cleanFloorAreaSteps(
|
|
config: ServerConfig,
|
|
models: readonly SpaceModel[],
|
|
geometryOf: typeof spaceWallGeometry = spaceWallGeometry,
|
|
): Generator<void, number | null, void> {
|
|
try {
|
|
let total = 0;
|
|
for (const space of models) {
|
|
const raw = config.spaces.find((item) => String(item?.id) === space.id);
|
|
if (!raw) continue;
|
|
const prepared = prepareSpacePhysicalGeometryInputs(raw, space);
|
|
// Один проход на пространство, не на комнату (#509).
|
|
const shared = geometryOf(space, prepared);
|
|
let spaceFloor: Geom | null = null;
|
|
for (const room of space.rooms) {
|
|
if (!room.id) continue;
|
|
const poly = roomPoly(room);
|
|
if (!poly) continue;
|
|
const inner = innerContourForRoom(
|
|
space.rooms, room.id, prepared.walls, prepared.openCuts,
|
|
GRID_STEP_N, prepared.cellCm, GRID_PITCH, NORM_W,
|
|
shared.roomGeom, shared.multiWallNodes,
|
|
) || poly;
|
|
const clean = floorMinusBodies(inner, prepared.physicalBodies) as Geom;
|
|
spaceFloor = unionGeometry(spaceFloor, clean);
|
|
yield;
|
|
}
|
|
// Difference distributes over union. Subtract the shared stair set once,
|
|
// but in bounded batches: 250 footprints in one polyclip sweep becomes a
|
|
// visible long task on slower clients (#663).
|
|
const stairSteps = geometryMinusStairsSteps(spaceFloor || [], space.stairs);
|
|
let stairStep = stairSteps.next();
|
|
while (!stairStep.done) {
|
|
yield;
|
|
stairStep = stairSteps.next();
|
|
}
|
|
spaceFloor = stairStep.value;
|
|
const cmPerUnit = prepared.cellCm / GRID_PITCH;
|
|
total += geometryArea(spaceFloor) * cmPerUnit * cmPerUnit / 1e4;
|
|
}
|
|
return total;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Canonical clean-floor union, in physical square metres, for every space. */
|
|
export function totalCleanFloorAreaM2(
|
|
config: ServerConfig,
|
|
models: readonly SpaceModel[],
|
|
geometryOf: typeof spaceWallGeometry = spaceWallGeometry,
|
|
): number | null {
|
|
const steps = cleanFloorAreaSteps(config, models, geometryOf);
|
|
let step = steps.next();
|
|
while (!step.done) step = steps.next();
|
|
return step.value;
|
|
}
|
|
|
|
export function summarySystemValue(
|
|
source: SummaryPanelSource,
|
|
values: { deviceCount: number | null; areaM2: number | null; now: Date },
|
|
hass: SummaryHass | undefined,
|
|
locale: string,
|
|
): string | null {
|
|
if (source.type !== 'system') return null;
|
|
if (source.key === 'device_count') return values.deviceCount === null ? null : String(values.deviceCount);
|
|
if (source.key === 'total_area') {
|
|
if (values.areaM2 === null) return null;
|
|
return formatArea(values.areaM2, hass?.config?.unit_system?.length === 'mi');
|
|
}
|
|
try {
|
|
return new Intl.DateTimeFormat(locale || undefined, {
|
|
dateStyle: 'short', timeStyle: 'short', timeZone: hass?.config?.time_zone || undefined,
|
|
}).format(values.now);
|
|
} catch {
|
|
return values.now.toLocaleString();
|
|
}
|
|
}
|
|
|
|
export function summaryEntityValue(hass: SummaryHass | undefined, entityId: string): string | null {
|
|
const state = hass?.states?.[entityId];
|
|
if (!state) return null;
|
|
const value = hassValue(hass, entityId);
|
|
if (!value) return null;
|
|
return valueWithUnit(value, String(state.attributes?.unit_of_measurement || ''));
|
|
}
|