Extract circuit grid projection

This commit is contained in:
2026-07-22 20:46:32 +02:00
parent 009152b406
commit 6c45c97ffd
5 changed files with 343 additions and 171 deletions
+1
View File
@@ -8,6 +8,7 @@ The circuit-list editor is a circuit-first planning workspace for distribution-b
- `circuit-tree-editor.tsx` owns React state, API commands, undo/redo and drag-and-drop orchestration.
- `circuit-grid-model.ts` owns column metadata, circuit/device cell ownership, value projection, formatting, numeric parsing and block sort values.
- `circuit-grid-projection.ts` owns block-preserving filtering/sorting and the normalized visible row projection.
- `circuit-grid-insertion.ts` resolves insertion intent and insertion sort positions.
- `circuit-grid-safety.ts` resolves delete intent, BMK conflicts and cross-section move confirmation requirements.
+2 -2
View File
@@ -11,8 +11,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js",
+10 -169
View File
@@ -17,16 +17,11 @@ import {
} from "../utils/circuit-grid-safety";
import {
allColumns,
circuitOnlyColumns,
compareSortValues,
defaultVisibleColumnKeys,
deviceFieldKeys,
formatValue,
getBlockSortValue,
getCellKind,
getCircuitValue,
getDeviceValue,
normalizeFilterValue,
parseNumeric,
} from "../utils/circuit-grid-model";
import type {
@@ -35,6 +30,12 @@ import type {
ColumnDef,
RowType,
} from "../utils/circuit-grid-model";
import {
buildVisibleGridRows,
filterAndSortCircuitSections,
getDistinctFilterValues,
} from "../utils/circuit-grid-projection";
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import {
createCircuit,
createCircuitDeviceRow,
@@ -82,22 +83,6 @@ interface SelectionIntent {
deviceId?: string;
}
interface VisibleGridCell {
cellKey: CellKey;
editable: boolean;
kind: CellKind;
value: string | number | boolean | undefined;
}
interface VisibleGridRow {
rowKey: string;
rowType: RowType;
sectionId: string;
circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto;
cells: VisibleGridCell[];
}
interface HistoryCommand {
label: string;
redo: () => Promise<SelectionIntent | null | void>;
@@ -332,129 +317,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const distinctValuesByColumn = useMemo(() => {
// Filter options are generated only for visible columns to match current header UI.
// Hidden columns still exist in row model, but are intentionally not filterable from header.
const result = {} as Record<CellKey, string[]>;
for (const column of visibleColumns) {
const set = new Set<string>();
for (const section of data?.sections ?? []) {
for (const circuit of section.circuits) {
set.add(normalizeFilterValue(getCircuitValue(circuit, column.key)));
for (const row of circuit.deviceRows) {
set.add(normalizeFilterValue(getDeviceValue(row, column.key)));
}
}
}
result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}
return result;
return getDistinctFilterValues(data?.sections ?? [], visibleColumns);
}, [data, visibleColumns]);
const filteredSortedSections = useMemo(() => {
// Filtering and sorting are frontend view state. Backend sort order remains unchanged
// until user explicitly applies sorted order.
if (!data) {
return [] as CircuitTreeResponseDto["sections"];
}
const matchesColumnFilter = (
circuit: CircuitTreeCircuitDto,
row: CircuitTreeDeviceRowDto | null,
key: CellKey,
selected: string[]
) => {
if (!selected.length) {
return true;
}
const values = new Set<string>();
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
if (row) {
values.add(normalizeFilterValue(getDeviceValue(row, key)));
} else {
for (const device of circuit.deviceRows) {
values.add(normalizeFilterValue(getDeviceValue(device, key)));
}
}
return selected.some((entry) => values.has(entry));
};
const sections = data.sections
.map((section) => {
let circuits = section.circuits
.map((circuit) => {
const selectedFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0);
const circuitMatchesAll = selectedFilters.every(([key, values]) =>
matchesColumnFilter(circuit, null, key as CellKey, values ?? [])
);
if (!circuitMatchesAll) {
return null;
}
if (!hasActiveFilters || circuit.deviceRows.length <= 1) {
return circuit;
}
const matchingRows = circuit.deviceRows.filter((row) =>
selectedFilters.every(([key, values]) =>
matchesColumnFilter(circuit, row, key as CellKey, values ?? [])
)
);
if (matchingRows.length > 0) {
return { ...circuit, deviceRows: matchingRows };
}
if (selectedFilters.some(([key]) => circuitOnlyColumns.has(key as CellKey))) {
return circuit;
}
return null;
})
.filter(Boolean) as CircuitTreeCircuitDto[];
if (sortState) {
// Sort on circuit blocks so multi-device circuits keep row grouping integrity.
circuits = [...circuits].sort((a, b) => {
const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key));
return sortState.direction === "asc" ? cmp : -cmp;
});
}
return { ...section, circuits };
})
.filter((section) => section.circuits.length > 0);
return sections;
}, [data, columnFilters, hasActiveFilters, sortState]);
return filterAndSortCircuitSections(data?.sections ?? [], columnFilters, sortState);
}, [data, columnFilters, sortState]);
// visibleRows is the single normalized grid used by render + navigation + editability.
// This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads.
const visibleRows = useMemo(() => {
if (!filteredSortedSections.length) {
return [] as VisibleGridRow[];
}
const rows: VisibleGridRow[] = [];
for (const section of filteredSortedSections) {
rows.push({
rowKey: `section:${section.id}`,
rowType: "section",
sectionId: section.id,
// Section headers are structural markers only: visible, but never editable/selectable.
cells: allColumns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })),
});
for (const circuit of section.circuits) {
if (circuit.deviceRows.length === 0) {
rows.push(makeRow("reserveCircuit", section.id, circuit));
continue;
}
if (circuit.deviceRows.length === 1) {
rows.push(makeRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
continue;
}
rows.push(makeRow("circuitSummary", section.id, circuit));
for (const device of circuit.deviceRows) {
rows.push(makeRow("deviceRow", section.id, circuit, device));
}
}
// Placeholder row is virtual (-frei-) but intentionally part of editable grid because
// editing/dropping here is the fast path to create a real circuit in this section.
rows.push(makeRow("placeholder", section.id));
}
return rows;
return buildVisibleGridRows(filteredSortedSections);
}, [filteredSortedSections]);
const searchableProjectDevices = useMemo(() => {
@@ -490,40 +365,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
[allCircuits]
);
function makeRow(
rowType: RowType,
sectionId: string,
circuit?: CircuitTreeCircuitDto,
device?: CircuitTreeDeviceRowDto
): VisibleGridRow {
// Row keys are UI identities, not persisted identities. They are rebuilt on each tree refresh.
const rowKey =
rowType === "placeholder"
? `placeholder:${sectionId}`
: rowType === "deviceRow" && device
? `device:${device.id}`
: `${rowType}:${circuit?.id ?? sectionId}`;
const cells = allColumns.map((col) => {
const kind = getCellKind(rowType, col.key);
// Hidden columns remain in the normalized row data; only visibility controls rendering.
const editable = kind === "circuitField" || kind === "deviceField";
let value: string | number | boolean | undefined;
if (rowType === "placeholder") {
value = col.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) {
value = getDeviceValue(device, col.key);
} else if (kind === "circuitField" && circuit) {
value = getCircuitValue(circuit, col.key);
} else if (col.key === "circuitTotalPower" && circuit) {
value = circuit.circuitTotalPower;
} else if (col.key === "rowTotalPower" && device) {
value = device.rowTotalPower;
}
return { cellKey: col.key, editable, kind, value };
});
return { rowKey, rowType, sectionId, circuit, device, cells };
}
const editableCells = useMemo(
() =>
visibleRows.flatMap((row) =>
@@ -0,0 +1,186 @@
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../types.js";
import {
allColumns,
circuitOnlyColumns,
compareSortValues,
deviceFieldKeys,
getBlockSortValue,
getCellKind,
getCircuitValue,
getDeviceValue,
normalizeFilterValue,
} from "./circuit-grid-model";
import type {
CellKey,
CellKind,
ColumnDef,
GridValue,
RowType,
} from "./circuit-grid-model";
export type ColumnFilters = Partial<Record<CellKey, string[]>>;
export interface GridSortState {
key: CellKey;
direction: "asc" | "desc";
}
export interface VisibleGridCell {
cellKey: CellKey;
editable: boolean;
kind: CellKind;
value: GridValue;
}
export interface VisibleGridRow {
rowKey: string;
rowType: RowType;
sectionId: string;
circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto;
cells: VisibleGridCell[];
}
function getCircuitBlockFilterValues(circuit: CircuitTreeCircuitDto, key: CellKey): Set<string> {
const values = new Set<string>();
if (circuitOnlyColumns.has(key) || key === "displayName" || key === "remark") {
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
}
if (!circuitOnlyColumns.has(key)) {
for (const device of circuit.deviceRows) {
values.add(normalizeFilterValue(getDeviceValue(device, key)));
}
}
if (values.size === 0 && deviceFieldKeys.has(key)) {
values.add(normalizeFilterValue(undefined));
}
return values;
}
export function getDistinctFilterValues(
sections: readonly CircuitTreeSectionDto[],
columns: readonly ColumnDef[]
): Record<CellKey, string[]> {
const result = {} as Record<CellKey, string[]>;
for (const column of columns) {
const values = new Set<string>();
for (const section of sections) {
for (const circuit of section.circuits) {
for (const value of getCircuitBlockFilterValues(circuit, column.key)) {
values.add(value);
}
}
}
result[column.key] = [...values].sort((left, right) =>
left.localeCompare(right, undefined, { numeric: true })
);
}
return result;
}
export function filterAndSortCircuitSections(
sections: readonly CircuitTreeSectionDto[],
columnFilters: ColumnFilters,
sortState: GridSortState | null
): CircuitTreeSectionDto[] {
const activeFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0) as Array<
[CellKey, string[]]
>;
return sections
.map((section) => {
let circuits = section.circuits.filter((circuit) =>
activeFilters.every(([key, selectedValues]) => {
const blockValues = getCircuitBlockFilterValues(circuit, key);
return selectedValues.some((selectedValue) => blockValues.has(selectedValue));
})
);
if (sortState) {
// Circuit blocks are sorted as units; their device row membership/order is untouched.
circuits = [...circuits].sort((left, right) => {
const comparison = compareSortValues(
getBlockSortValue(left, sortState.key),
getBlockSortValue(right, sortState.key)
);
return sortState.direction === "asc" ? comparison : -comparison;
});
}
return { ...section, circuits };
})
.filter((section) => section.circuits.length > 0);
}
function makeVisibleGridRow(
rowType: RowType,
sectionId: string,
circuit?: CircuitTreeCircuitDto,
device?: CircuitTreeDeviceRowDto
): VisibleGridRow {
const rowKey =
rowType === "placeholder"
? `placeholder:${sectionId}`
: rowType === "deviceRow" && device
? `device:${device.id}`
: `${rowType}:${circuit?.id ?? sectionId}`;
const cells = allColumns.map((column): VisibleGridCell => {
const kind = getCellKind(rowType, column.key);
const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue;
if (rowType === "placeholder") {
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) {
value = getDeviceValue(device, column.key);
} else if (kind === "circuitField" && circuit) {
value = getCircuitValue(circuit, column.key);
} else if (column.key === "circuitTotalPower" && circuit) {
value = circuit.circuitTotalPower;
} else if (column.key === "rowTotalPower" && device) {
value = device.rowTotalPower;
}
return { cellKey: column.key, editable, kind, value };
});
return { rowKey, rowType, sectionId, circuit, device, cells };
}
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
const rows: VisibleGridRow[] = [];
for (const section of sections) {
rows.push({
rowKey: `section:${section.id}`,
rowType: "section",
sectionId: section.id,
cells: allColumns.map((column) => ({
cellKey: column.key,
editable: false,
kind: "readonly",
value: undefined,
})),
});
for (const circuit of section.circuits) {
if (circuit.deviceRows.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
} else if (circuit.deviceRows.length === 1) {
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else {
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
for (const device of circuit.deviceRows) {
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
}
}
}
rows.push(makeVisibleGridRow("placeholder", section.id));
}
return rows;
}
+144
View File
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildVisibleGridRows,
filterAndSortCircuitSections,
getDistinctFilterValues,
} from "../src/frontend/utils/circuit-grid-projection.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
function makeDevice(
id: string,
displayName: string,
roomNumberSnapshot: string,
sortOrder: number
): CircuitTreeDeviceRowDto {
return {
id,
sortOrder,
name: displayName,
displayName,
roomNumberSnapshot,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
rowTotalPower: 0.1,
};
}
function makeCircuit(
id: string,
equipmentIdentifier: string,
displayName: string,
deviceRows: CircuitTreeDeviceRowDto[],
sortOrder: number
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier,
displayName,
sortOrder,
isReserve: deviceRows.length === 0,
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
deviceRows,
};
}
const multiDeviceCircuit = makeCircuit(
"circuit-multi",
"-1F2",
"Office lighting",
[makeDevice("device-office", "Office light", "1.01", 10), makeDevice("device-store", "Store light", "1.02", 20)],
20
);
const compactCircuit = makeCircuit(
"circuit-compact",
"-1F1",
"Corridor lighting",
[makeDevice("device-corridor", "Corridor light", "1.03", 10)],
10
);
const reserveCircuit = makeCircuit("circuit-reserve", "-1F3", "Reserve", [], 30);
const sections: CircuitTreeSectionDto[] = [
{
id: "section-1",
key: "lighting",
displayName: "Lighting",
prefix: "-1F",
sortOrder: 10,
circuits: [compactCircuit, multiDeviceCircuit, reserveCircuit],
},
];
describe("circuit grid projection", () => {
it("keeps every device row in a circuit block when one device matches a filter", () => {
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["1.01"] }, null);
assert.deepEqual(result.map((section) => section.circuits.map((circuit) => circuit.id)), [["circuit-multi"]]);
assert.deepEqual(result[0].circuits[0].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
});
it("combines column filters across the complete circuit block", () => {
const result = filterAndSortCircuitSections(
sections,
{ equipmentIdentifier: ["-1F2"], roomNumberSnapshot: ["1.02"] },
null
);
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-multi"]);
});
it("treats missing device values as missing without matching populated circuits", () => {
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["-"] }, null);
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-reserve"]);
});
it("sorts complete circuit blocks without changing device row order", () => {
const result = filterAndSortCircuitSections(sections, {}, { key: "equipmentIdentifier", direction: "desc" });
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), [
"circuit-reserve",
"circuit-multi",
"circuit-compact",
]);
assert.deepEqual(result[0].circuits[1].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
});
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
const rows = buildVisibleGridRows(sections);
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
"circuitCompact",
"circuitSummary",
"deviceRow",
"deviceRow",
"reserveCircuit",
"placeholder",
]);
assert.equal(rows.at(-1)?.rowKey, "placeholder:section-1");
assert.equal(
rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value,
"-frei-"
);
});
it("collects filter options from both circuit and device values", () => {
const values = getDistinctFilterValues(sections, [
{ key: "displayName", label: "Display name" },
{ key: "roomNumberSnapshot", label: "Room number" },
]);
assert.ok(values.displayName.includes("Office lighting"));
assert.ok(values.displayName.includes("Store light"));
assert.ok(values.roomNumberSnapshot.includes("1.03"));
});
});