// Pure selection, filtering and grouping rules for the building structure // view (Gebäude → Etagen → Räume) and grouped floor selects. import type { BuildingDto, FloorDto, RoomDto } from "../types"; /** Selection key for "Alle" in the building and floor columns. */ export const ALL_SELECTION = "__all__"; /** Selection key for "Ohne Gebäude" / "Ohne Etage". */ export const UNASSIGNED_SELECTION = "__unassigned__"; /** ALL_SELECTION, UNASSIGNED_SELECTION or a building/floor id. */ export type StructureSelection = string; export const UNASSIGNED_BUILDING_LABEL = "Ohne Gebäude"; export const UNASSIGNED_FLOOR_LABEL = "Ohne Etage"; type BuildingLike = Pick; type FloorLike = Pick & { buildingId?: string | null; }; type RoomLike = Pick; type RoomAreaLike = Pick; export interface RoomAreaSummary { areaM2: number; roomCount: number; missingAreaCount: number; } export interface StructureAreaSummaries { total: RoomAreaSummary; byBuilding: Map; byFloor: Map; } export interface FloorGroup { /** null for floors without (known) building. */ buildingId: string | null; label: string; floors: TFloor[]; } /** * The building a floor belongs to, or null when it has none or references a * building that is not (or no longer) loaded. */ export function resolveFloorBuildingId( floor: FloorLike, buildingIds: ReadonlySet ): string | null { const buildingId = floor.buildingId ?? null; return buildingId !== null && buildingIds.has(buildingId) ? buildingId : null; } function toIdSet(entries: ReadonlyArray<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); } export function hasFloorsWithoutBuilding( floors: ReadonlyArray, buildings: ReadonlyArray ): boolean { const buildingIds = toIdSet(buildings); return floors.some( (floor) => resolveFloorBuildingId(floor, buildingIds) === null ); } export function hasRoomsWithoutFloor( rooms: ReadonlyArray, floors: ReadonlyArray ): boolean { const floorIds = toIdSet(floors); return rooms.some( (room) => room.floorId === null || !floorIds.has(room.floorId) ); } /** * Groups floors by building in building order; floors without a known * building come last under "Ohne Gebäude". Empty groups are omitted and the * floor order inside a group is preserved. */ export function groupFloorsByBuilding( floors: ReadonlyArray, buildings: ReadonlyArray ): FloorGroup[] { const buildingIds = toIdSet(buildings); const floorsByBuilding = new Map(); for (const floor of floors) { const buildingId = resolveFloorBuildingId(floor, buildingIds); const group = floorsByBuilding.get(buildingId) ?? []; group.push(floor); floorsByBuilding.set(buildingId, group); } const groups: FloorGroup[] = []; for (const building of buildings) { const groupFloors = floorsByBuilding.get(building.id); if (groupFloors?.length) { groups.push({ buildingId: building.id, label: building.name, floors: groupFloors, }); } } const unassigned = floorsByBuilding.get(null); if (unassigned?.length) { groups.push({ buildingId: null, label: UNASSIGNED_BUILDING_LABEL, floors: unassigned, }); } return groups; } /** Floors shown in the floor column for a building selection. */ export function filterFloorsByBuilding( floors: ReadonlyArray, buildings: ReadonlyArray, buildingSelection: StructureSelection ): TFloor[] { if (buildingSelection === ALL_SELECTION) { return [...floors]; } const buildingIds = toIdSet(buildings); const targetBuildingId = buildingSelection === UNASSIGNED_SELECTION ? null : buildingSelection; return floors.filter( (floor) => resolveFloorBuildingId(floor, buildingIds) === targetBuildingId ); } /** * "Ohne Etage" is offered only when rooms without floor exist and the * building selection can contain them ("Alle" or "Ohne Gebäude"), because a * room without floor has no building either. */ export function showsRoomsWithoutFloorEntry( rooms: ReadonlyArray, floors: ReadonlyArray, buildingSelection: StructureSelection ): boolean { return ( (buildingSelection === ALL_SELECTION || buildingSelection === UNASSIGNED_SELECTION) && hasRoomsWithoutFloor(rooms, floors) ); } /** Rooms shown in the room column for the current selections. */ export function filterRooms( rooms: ReadonlyArray, floors: ReadonlyArray, buildings: ReadonlyArray, buildingSelection: StructureSelection, floorSelection: StructureSelection ): TRoom[] { const floorIds = toIdSet(floors); const isWithoutFloor = (room: RoomLike) => room.floorId === null || !floorIds.has(room.floorId); if (floorSelection === UNASSIGNED_SELECTION) { return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection) ? rooms.filter(isWithoutFloor) : []; } if (floorSelection !== ALL_SELECTION) { return rooms.filter((room) => room.floorId === floorSelection); } if (buildingSelection === ALL_SELECTION) { return [...rooms]; } const visibleFloorIds = toIdSet( filterFloorsByBuilding(floors, buildings, buildingSelection) ); return rooms.filter( (room) => (room.floorId !== null && visibleFloorIds.has(room.floorId)) || (buildingSelection === UNASSIGNED_SELECTION && isWithoutFloor(room)) ); } /** Falls back to "Alle" when a selected building vanished. */ export function normalizeBuildingSelection( selection: StructureSelection, floors: ReadonlyArray, buildings: ReadonlyArray ): StructureSelection { if (selection === ALL_SELECTION) { return selection; } if (selection === UNASSIGNED_SELECTION) { return hasFloorsWithoutBuilding(floors, buildings) ? selection : ALL_SELECTION; } return buildings.some((building) => building.id === selection) ? selection : ALL_SELECTION; } /** Falls back to "Alle" when a selected floor is no longer visible. */ export function normalizeFloorSelection( selection: StructureSelection, rooms: ReadonlyArray, floors: ReadonlyArray, buildings: ReadonlyArray, buildingSelection: StructureSelection ): StructureSelection { if (selection === ALL_SELECTION) { return selection; } if (selection === UNASSIGNED_SELECTION) { return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection) ? selection : ALL_SELECTION; } return filterFloorsByBuilding(floors, buildings, buildingSelection).some( (floor) => floor.id === selection ) ? selection : ALL_SELECTION; } /** Building preselected in the floor dialog ("" = Ohne Gebäude). */ export function defaultBuildingIdForNewFloor( buildingSelection: StructureSelection ): string { return buildingSelection === ALL_SELECTION || buildingSelection === UNASSIGNED_SELECTION ? "" : buildingSelection; } /** Floor preselected in the room dialog ("" = Ohne Etage). */ export function defaultFloorIdForNewRoom( floorSelection: StructureSelection ): string { return floorSelection === ALL_SELECTION || floorSelection === UNASSIGNED_SELECTION ? "" : floorSelection; } export function countFloorsByBuilding( floors: ReadonlyArray, buildings: ReadonlyArray ): Map { const buildingIds = toIdSet(buildings); const counts = new Map(); for (const floor of floors) { const buildingId = resolveFloorBuildingId(floor, buildingIds); counts.set(buildingId, (counts.get(buildingId) ?? 0) + 1); } return counts; } /** Room counts per floor id; rooms without known floor count under null. */ export function countRoomsByFloor( rooms: ReadonlyArray, floors: ReadonlyArray ): Map { const floorIds = toIdSet(floors); const counts = new Map(); for (const room of rooms) { const floorId = room.floorId !== null && floorIds.has(room.floorId) ? room.floorId : null; counts.set(floorId, (counts.get(floorId) ?? 0) + 1); } return counts; } /** * Sums only known room areas and counts missing values separately. Rooms with * no known floor/building are grouped under null for the corresponding UI * entries "Ohne Etage" and "Ohne Gebäude". */ export function summarizeRoomAreas( rooms: ReadonlyArray, floors: ReadonlyArray, buildings: ReadonlyArray ): StructureAreaSummaries { const buildingIds = toIdSet(buildings); const floorsById = new Map(floors.map((floor) => [floor.id, floor])); const byBuilding = new Map(); const byFloor = new Map(); const total = emptyAreaSummary(); for (const room of rooms) { const floor = room.floorId === null ? undefined : floorsById.get(room.floorId); const floorId = floor?.id ?? null; const buildingId = floor ? resolveFloorBuildingId(floor, buildingIds) : null; addRoomArea(total, room.areaM2); addRoomArea(summaryFor(byFloor, floorId), room.areaM2); addRoomArea(summaryFor(byBuilding, buildingId), room.areaM2); } return { total, byBuilding, byFloor }; } function emptyAreaSummary(): RoomAreaSummary { return { areaM2: 0, roomCount: 0, missingAreaCount: 0 }; } function summaryFor( summaries: Map, key: string | null ) { const existing = summaries.get(key); if (existing) return existing; const created = emptyAreaSummary(); summaries.set(key, created); return created; } function addRoomArea(summary: RoomAreaSummary, areaM2: number | null) { summary.roomCount += 1; if (areaM2 === null) { summary.missingAreaCount += 1; } else { summary.areaM2 += areaM2; } } /** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */ export function describeRoomLocation( room: RoomLike, floors: ReadonlyArray, buildings: ReadonlyArray ): string { const floor = room.floorId === null ? undefined : floors.find((entry) => entry.id === room.floorId); if (!floor) { return UNASSIGNED_FLOOR_LABEL; } const buildingId = resolveFloorBuildingId(floor, toIdSet(buildings)); const building = buildingId === null ? undefined : buildings.find((entry) => entry.id === buildingId); return building ? `${floor.name} · ${building.name}` : floor.name; }