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
+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) =>