Render protected circuit structure

This commit is contained in:
2026-07-31 07:22:26 +02:00
parent ac21d6eb5d
commit 31589875b5
11 changed files with 254 additions and 29 deletions
+9 -3
View File
@@ -355,6 +355,11 @@ rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten, Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert. sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
Gruppen- und Fußkomponenten erscheinen dort zunächst als schreibgeschützte
Zeilen über die volle Tabellenbreite und nehmen nicht an Zellauswahl,
Tastatur-Einfügen oder Löschen teil.
`distribution-board.update` versioniert Etage, Netzart und den `distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
@@ -426,9 +431,10 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
und UI folgen in abgegrenzten Arbeitspaketen. zeigt die geschützte Struktur bereits schreibgeschützt an; dedizierte
Bearbeitungsfunktionen folgen in einem abgegrenzten Arbeitspaket.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+1 -1
View File
@@ -33,7 +33,7 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase D3b: persistent populated-group delete/restore command. - [x] Phase D3b: persistent populated-group delete/restore command.
- [x] Phase E1: tree read model for components, groups and protection devices. - [x] Phase E1: tree read model for components, groups and protection devices.
- [x] Phase E2a: pure header/group/footer structure projection. - [x] Phase E2a: pure header/group/footer structure projection.
- [ ] Phase E2b: render the structure projection in the editor grid. - [x] Phase E2b: render the structure projection in the editor grid.
- [ ] Phase E: editor projection and editing. - [ ] Phase E: editor projection and editing.
- [ ] Phase F: documentation and full GUI verification. - [ ] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until - [ ] Keep full electrical sizing and cable-dimensioning rules separate until
@@ -739,8 +739,9 @@ Acceptance:
### E. Editor Projection and Editing ### E. Editor Projection and Editing
Status: In progress. The complete tree read model E1 and pure structural Status: In progress. The complete tree read model E1, pure structural
projection E2a are complete. Grid rendering and editing remain pending. projection E2a and read-only grid rendering E2b are complete. Editing remains
pending.
- render fixed header components - render fixed header components
- render group components and circuit blocks - render group components and circuit blocks
@@ -749,7 +750,7 @@ projection E2a are complete. Grid rendering and editing remain pending.
- add type-dependent protection editing - add type-dependent protection editing
- keep spreadsheet selection, keyboard and drag behavior coherent - keep spreadsheet selection, keyboard and drag behavior coherent
Implemented in E1/E2a: Implemented in E1/E2a/E2b:
- the tree response exposes fixed header, group and auxiliary footer - the tree response exposes fixed header, group and auxiliary footer
components plus both one-to-one protection layers components plus both one-to-one protection layers
@@ -759,6 +760,10 @@ Implemented in E1/E2a:
circuit blocks and free placeholder together circuit blocks and free placeholder together
- empty groups remain visible and circuit device rows remain owned by their - empty groups remain visible and circuit device rows remain owned by their
circuit block circuit block
- the editor renders fixed header, group protection and auxiliary footer
components as read-only full-width rows
- structural component rows are excluded from spreadsheet cell navigation,
insertion and deletion commands until their dedicated editing controls exist
Acceptance: Acceptance:
+34
View File
@@ -657,6 +657,40 @@ body {
font-weight: 600; font-weight: 600;
} }
.tree-grid .structure-component-row td {
padding: 0.38rem 0.6rem;
border-bottom-color: #d8dee9;
}
.tree-grid .structure-component-row.headerComponent td {
background: #e2e8f0;
}
.tree-grid .structure-component-row.groupComponent td {
background: #f8fafc;
}
.tree-grid .structure-component-row.footerComponent td {
background: #f1f5f9;
}
.tree-grid .structure-component-content {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.tree-grid .structure-component-content strong {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.tree-grid .structure-component-protection {
color: #475569;
font-size: 0.78rem;
}
.tree-grid .section-drop-cell { .tree-grid .section-drop-cell {
padding: 0.45rem 0.6rem; padding: 0.45rem 0.6rem;
} }
@@ -11,6 +11,7 @@ import {
} from "../../domain/services/project-voltage.service"; } from "../../domain/services/project-voltage.service";
import { import {
getInsertionSortOrder, getInsertionSortOrder,
isGridInsertionRowType,
resolveGridInsertionIntent, resolveGridInsertionIntent,
} from "../utils/circuit-grid-insertion"; } from "../utils/circuit-grid-insertion";
import { import {
@@ -53,7 +54,7 @@ import type {
RowType, RowType,
} from "../utils/circuit-grid-model"; } from "../utils/circuit-grid-model";
import { import {
buildVisibleGridRows, buildVisibleGridRowsWithStructure,
filterAndSortCircuitSections, filterAndSortCircuitSections,
getDistinctFilterValues, getDistinctFilterValues,
} from "../utils/circuit-grid-projection"; } from "../utils/circuit-grid-projection";
@@ -386,8 +387,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// visibleRows is the single normalized grid used by render + navigation + editability. // visibleRows is the single normalized grid used by render + navigation + editability.
// This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads. // This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads.
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
return buildVisibleGridRows(filteredSortedSections); return buildVisibleGridRowsWithStructure(filteredSortedSections, {
}, [filteredSortedSections]); headerComponents: data?.headerComponents ?? [],
footerComponents: data?.footerComponents ?? [],
});
}, [data, filteredSortedSections]);
function clearColumnFilter(key: CellKey) { function clearColumnFilter(key: CellKey) {
setColumnFilters((current) => { setColumnFilters((current) => {
@@ -1660,7 +1664,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const row = selectedCell ? findRow(selectedCell.rowKey) : null; const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const selection = const selection =
row && cell && row.rowType !== "section" row && cell && isGridInsertionRowType(row.rowType)
? { ? {
rowType: row.rowType, rowType: row.rowType,
cellKind: cell.kind, cellKind: cell.kind,
@@ -2351,7 +2355,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const row = selectedCell ? findRow(selectedCell.rowKey) : null; const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const intent = resolveGridDeleteIntent( const intent = resolveGridDeleteIntent(
row && cell && row.rowType !== "section" row && cell && isGridInsertionRowType(row.rowType)
? { ? {
rowType: row.rowType, rowType: row.rowType,
cellKind: cell.kind, cellKind: cell.kind,
@@ -2986,6 +2990,54 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</thead> </thead>
<tbody> <tbody>
{visibleRows.map((row) => { {visibleRows.map((row) => {
if (
row.rowType === "headerComponent" ||
row.rowType === "groupComponent" ||
row.rowType === "footerComponent"
) {
const component = row.component!;
const protection = component.protectionDevice;
const protectionSummary = protection
? [
protection.type,
`${formatValue(
protection.ratedCurrentA,
"protectionRatedCurrent"
)} A`,
protection.fuseUtilizationCategory,
protection.tripCharacteristic,
protection.rcdType
? `Typ ${protection.rcdType}`
: undefined,
protection.ratedResidualCurrentMa !== undefined
? `${formatValue(
protection.ratedResidualCurrentMa,
"protectionRatedCurrent"
)} mA`
: undefined,
]
.filter(Boolean)
.join(" · ")
: null;
return (
<tr
key={row.rowKey}
className={`structure-component-row ${row.rowType}`}
>
<td colSpan={visibleColumns.length + 1}>
<div className="structure-component-content">
<strong>{component.equipmentIdentifier}</strong>
<span>{component.name}</span>
{protectionSummary ? (
<span className="structure-component-protection">
{protectionSummary}
</span>
) : null}
</div>
</td>
</tr>
);
}
if (row.rowType === "section") { if (row.rowType === "section") {
const section = data.sections.find((entry) => entry.id === row.sectionId)!; const section = data.sections.find((entry) => entry.id === row.sectionId)!;
return ( return (
@@ -7,6 +7,16 @@ export type GridInsertionRowType =
export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed"; export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed";
export function isGridInsertionRowType(rowType: string): rowType is GridInsertionRowType {
return (
rowType === "circuitCompact" ||
rowType === "circuitSummary" ||
rowType === "deviceRow" ||
rowType === "reserveCircuit" ||
rowType === "placeholder"
);
}
export interface GridInsertionSelection { export interface GridInsertionSelection {
rowType: GridInsertionRowType; rowType: GridInsertionRowType;
cellKind: GridInsertionCellKind; cellKind: GridInsertionCellKind;
+4 -1
View File
@@ -37,12 +37,15 @@ export type CellKey =
| "isReserve"; | "isReserve";
export type RowType = export type RowType =
| "headerComponent"
| "section" | "section"
| "groupComponent"
| "circuitCompact" | "circuitCompact"
| "circuitSummary" | "circuitSummary"
| "deviceRow" | "deviceRow"
| "reserveCircuit" | "reserveCircuit"
| "placeholder"; | "placeholder"
| "footerComponent";
export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly"; export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
export type GridValue = string | number | boolean | undefined; export type GridValue = string | number | boolean | undefined;
+61 -15
View File
@@ -1,8 +1,10 @@
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto, CircuitTreeDeviceRowDto,
CircuitTreeSectionDto, CircuitTreeSectionDto,
} from "../types.js"; } from "../types.js";
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
import { import {
allColumns, allColumns,
circuitOnlyColumns, circuitOnlyColumns,
@@ -42,6 +44,7 @@ export interface VisibleGridRow {
sectionId: string; sectionId: string;
circuit?: CircuitTreeCircuitDto; circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto; device?: CircuitTreeDeviceRowDto;
component?: CircuitTreeComponentDto;
cells: VisibleGridCell[]; cells: VisibleGridCell[];
} }
@@ -160,21 +163,64 @@ function makeVisibleGridRow(
} }
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] { export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
const rows: VisibleGridRow[] = []; return buildVisibleGridRowsWithStructure(sections, {
for (const section of sections) { headerComponents: [],
rows.push({ footerComponents: [],
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) { export function buildVisibleGridRowsWithStructure(
sections: readonly CircuitTreeSectionDto[],
structure: {
headerComponents: readonly CircuitTreeComponentDto[];
footerComponents: readonly CircuitTreeComponentDto[];
}
): VisibleGridRow[] {
const rows: VisibleGridRow[] = [];
const projected = buildCircuitStructureProjection({
headerComponents: [...structure.headerComponents],
sections: [...sections],
footerComponents: [...structure.footerComponents],
});
for (const row of projected) {
if ("component" in row) {
const component = row.component;
rows.push({
rowKey: row.rowKey,
rowType: row.rowType,
sectionId: component.sectionId ?? `__${row.zone}__`,
component,
cells: allColumns.map((column) => ({
cellKey: column.key,
editable: false,
kind: "readonly",
value:
column.key === "equipmentIdentifier"
? component.equipmentIdentifier
: column.key === "displayName"
? component.name
: undefined,
})),
});
continue;
}
const section = row.section;
if (row.rowType === "groupHeader") {
rows.push({
rowKey: `section:${section.id}`,
rowType: "section",
sectionId: section.id,
cells: allColumns.map((column) => ({
cellKey: column.key,
editable: false,
kind: "readonly",
value: undefined,
})),
});
continue;
}
if (row.rowType === "circuitBlock") {
const circuit = row.circuit;
if (circuit.deviceRows.length === 0) { if (circuit.deviceRows.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
} else if (circuit.deviceRows.length === 1) { } else if (circuit.deviceRows.length === 1) {
@@ -185,8 +231,8 @@ export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[])
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device)); rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
} }
} }
continue;
} }
rows.push(makeVisibleGridRow("placeholder", section.id)); rows.push(makeVisibleGridRow("placeholder", section.id));
} }
return rows; return rows;
@@ -55,7 +55,7 @@ export function buildCircuitStructureProjection(
zone: "group", zone: "group",
section, section,
}); });
for (const component of ordered(section.components)) { for (const component of ordered(section.components ?? [])) {
rows.push({ rows.push({
rowKey: `group-component:${component.id}`, rowKey: `group-component:${component.id}`,
rowType: "groupComponent", rowType: "groupComponent",
+8
View File
@@ -2,10 +2,18 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
getInsertionSortOrder, getInsertionSortOrder,
isGridInsertionRowType,
resolveGridInsertionIntent, resolveGridInsertionIntent,
} from "../src/frontend/utils/circuit-grid-insertion.js"; } from "../src/frontend/utils/circuit-grid-insertion.js";
describe("circuit grid insertion", () => { describe("circuit grid insertion", () => {
it("excludes structural rows from insertion and deletion commands", () => {
assert.equal(isGridInsertionRowType("headerComponent"), false);
assert.equal(isGridInsertionRowType("groupComponent"), false);
assert.equal(isGridInsertionRowType("footerComponent"), false);
assert.equal(isGridInsertionRowType("circuitCompact"), true);
});
it("inserts a circuit after circuit-level selections", () => { it("inserts a circuit after circuit-level selections", () => {
assert.deepEqual( assert.deepEqual(
resolveGridInsertionIntent( resolveGridInsertionIntent(
+61
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
buildVisibleGridRows, buildVisibleGridRows,
buildVisibleGridRowsWithStructure,
filterAndSortCircuitSections, filterAndSortCircuitSections,
getDistinctFilterValues, getDistinctFilterValues,
} from "../src/frontend/utils/circuit-grid-projection.js"; } from "../src/frontend/utils/circuit-grid-projection.js";
@@ -176,6 +177,66 @@ describe("circuit grid projection", () => {
); );
}); });
it("normalizes structural component rows around complete circuit groups", () => {
const component = {
circuitListId: "list-1",
equipmentIdentifier: "-Q0",
name: "Hauptschalter",
role: "main_switch" as const,
placement: "header" as const,
sortOrder: 10,
};
const groupedSections = sections.map((section) => ({
...section,
components: [
{
...component,
id: "group-rcd",
sectionId: section.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
},
],
}));
const rows = buildVisibleGridRowsWithStructure(groupedSections, {
headerComponents: [{ ...component, id: "main" }],
footerComponents: [
{
...component,
id: "actor",
equipmentIdentifier: "-K1",
name: "KNX Aktor",
role: "auxiliary",
placement: "footer",
},
],
});
assert.deepEqual(
rows.map((row) => row.rowType),
[
"headerComponent",
"section",
"groupComponent",
"circuitCompact",
"circuitSummary",
"deviceRow",
"deviceRow",
"reserveCircuit",
"placeholder",
"footerComponent",
]
);
assert.equal(
rows.find((row) => row.rowType === "groupComponent")?.cells.some(
(cell) => cell.editable
),
false
);
});
it("collects filter options from both circuit and device values", () => { it("collects filter options from both circuit and device values", () => {
const values = getDistinctFilterValues(sections, [ const values = getDistinctFilterValues(sections, [
{ key: "displayName", label: "Display name" }, { key: "displayName", label: "Display name" },