Files
houseplan-card/src/summary-panel-metrics.ts
T
2026-09-08 18:49:36 +03:00

118 lines
4.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 } 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';
/** 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);
}
/** Canonical clean-floor union, in physical square metres, for every space. */
export function totalCleanFloorAreaM2(
config: ServerConfig,
models: readonly SpaceModel[],
): number | null {
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);
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,
) || poly;
const clean = floorMinusBodies(inner, prepared.physicalBodies) as Geom;
spaceFloor = unionGeometry(spaceFloor, clean);
}
const cmPerUnit = prepared.cellCm / GRID_PITCH;
total += geometryArea(spaceFloor) * cmPerUnit * cmPerUnit / 1e4;
}
return total;
} catch {
return null;
}
}
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 || ''));
}