Files
DOSSIER-STANDALONE/src/state/projectSlice.ts
T
karim 633480252e Zeichnungsebenen-Panel: vier einklappbare Kategorien
Die flache Ebenenliste ist in vier einklappbare Kategorien gegliedert —
Geschosse/Schnitte/Ansichten/2D-Zeichnungen nach drawingLevel.kind. Jeder
Kopf trägt ein Chevron und rechtsbündig ein blankes +-Glyph (kein
Button-Chrome), das je Kategorie eine neue Ebene der passenden Art anlegt;
leere Kategorien bleiben mit Kopf und + sichtbar. Für Schnitt und Ansicht
sind dafür neue Platzhalter-Aktionen addSection/addElevation ergänzt
(analog addFloor/addDrawing, noch ohne Schnittlinie).
2026-07-03 23:31:39 +02:00

1451 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Projekt-Slice: das semantische Modell (`project`) plus ALLE Mutationen, die
// heute in App.tsx als Closures über setProject existieren. Reiner Umzug — die
// Logik ist 1:1 übernommen (inkl. recomputeFloorElevations, refs-aware delete,
// Tree-Helfern). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
//
// Cross-Slice-Zugriffe: einige Aktionen lesen/schreiben View-Felder
// (activeLevelId) — das geht, weil `get()`/`set` über den Gesamt-RootState
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
import { sampleProject } from "../model/sampleProject";
import {
flattenCategories,
recomputeFloorElevations,
} from "../model/types";
import type {
Component,
DrawingLevel,
HatchStyle,
LayerCategory,
LineStyle,
Project,
Vec2,
} from "../model/types";
import { commitTransform } from "../tools/transform";
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
import { centroid as roomCentroid } from "../geometry/roomArea";
import { t } from "../i18n";
import type { StoreApi } from "./store";
/** Felder, die diese Slice in den RootState beisteuert. */
export interface ProjectSlice {
project: Project;
// ── Projekt direkt setzen (für Werkzeuge/Transformationen aus App) ────────
/** Ersetzt das Projekt (Teil-/Updater wie React-setState). */
setProject: (next: Project | ((p: Project) => Project)) => void;
// ── Zeichnungsebenen (Levels/Geschosse) ──────────────────────────────────
toggleLevelVisible: (id: string) => void;
patchActiveFloor: (patch: Partial<DrawingLevel>) => void;
addFloor: () => void;
addDrawing: () => void;
/** Legt eine neue Schnitt-Zeichnungsebene (kind:"section") als Platzhalter an. */
addSection: () => void;
/** Legt eine neue Ansichts-Zeichnungsebene (kind:"elevation") als Platzhalter an. */
addElevation: () => void;
/**
* Legt eine neue freie Zeichnungsebene (kind:"drawing") mit dem gegebenen
* Namen an und liefert ihre ID zurück (z. B. als Ziel eines DXF-Imports).
*/
addDrawingLevel: (name: string) => string;
/** Hängt fertige Drawing2D-Elemente an (z. B. aus einem DXF-Import). */
importDrawings: (drawings: import("../model/types").Drawing2D[]) => void;
patchLevel: (id: string, patch: Partial<DrawingLevel>) => void;
duplicateLevel: (id: string) => void;
deleteLevel: (id: string) => void;
// ── Ebenen (Kategorien) ───────────────────────────────────────────────────
toggleCategoryVisible: (code: string) => void;
addCategory: () => void;
patchCategory: (code: string, patch: Partial<LayerCategory>) => void;
addSubCategory: (parentCode: string) => void;
duplicateCategory: (code: string) => void;
deleteCategory: (code: string) => void;
assignWallCategory: (wallId: string, code: string) => void;
// ── Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungs-Komb.) ────────────────
/** Liest je Kategorie-`code` → `visible` aus dem Layer-Baum (rekursiv). */
snapshotLayerVisibility: () => Record<string, boolean>;
/**
* Setzt im Layer-Baum jede Kategorie, deren `code` in `codes` vorkommt, auf
* den gespeicherten Wert (rekursiv, immutabel). Unbekannte Codes werden
* ignoriert; nicht enthaltene Kategorien bleiben unverändert.
*/
applyLayerVisibility: (codes: Record<string, boolean>) => void;
/** Liest je DrawingLevel-`id` → `visible`. */
snapshotDrawingVisibility: () => Record<string, boolean>;
/** Setzt `drawingLevels[].visible` gemäß `ids` (unbekannte Ids ignorieren). */
applyDrawingVisibility: (ids: Record<string, boolean>) => void;
// ── Ressourcen (Components / Hatches / LineStyles) ────────────────────────
patchComponent: (id: string, patch: Partial<Component>) => void;
addComponent: () => void;
deleteComponent: (id: string) => void;
patchHatch: (id: string, patch: Partial<HatchStyle>) => void;
addHatch: () => void;
deleteHatch: (id: string) => void;
patchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
addLineStyle: () => void;
deleteLineStyle: (id: string) => void;
importLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
importHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
// ── Editier-Griffe + Body-Move des selektierten Elements ──────────────────
moveGripOf: (
drawingId: string | null,
wallId: string | null,
index: number,
pt: Vec2,
) => void;
moveElementByOf: (
drawingId: string | null,
wallId: string | null,
delta: Vec2,
) => void;
/**
* Verschiebt eine SEITE (Kante) des selektierten Elements: BEIDE Vertices der
* Kante (`aIndex`/`bIndex`) um denselben `delta` (immutabel). Beim Rechteck
* werden min/max anschließend neu normalisiert; bei der Wand bewegt sich
* `start`+`end` gemeinsam (ganze Wand senkrecht).
*/
moveEdgeOf: (
drawingId: string | null,
wallId: string | null,
aIndex: number,
bIndex: number,
delta: Vec2,
) => void;
// ── Selektions-Attribute (für Attributes-/Object-Info-Paletten) ───────────
/** Setzt die (Strich-)Farbe eines Drawing2D bzw. einer Wand (Override). */
setElementColor: (
kind: "wall" | "drawing2d",
id: string,
color: string,
) => void;
/** Setzt die direkte Strichstärke (mm) — NUR Drawing2D (Wände haben keine). */
setElementWeight: (id: string, weightMm: number) => void;
/** Setzt/entfernt die Füllschraffur — NUR Drawing2D (geschlossene Formen). */
setElementFill: (id: string, hatchId: string | null) => void;
/**
* Setzt/entfernt die Vollton-Füllfarbe — NUR Drawing2D (geschlossene Formen).
* `null` entfernt das Feld (Fläche wieder transparent).
*/
setElementFillColor: (id: string, fillColor: string | null) => void;
/**
* Skaliert die Geometrie eines Elements auf Zielbreite×-höhe (Meter) um einen
* Ankerpunkt (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links).
*/
resizeElement: (
kind: "wall" | "drawing2d",
id: string,
w: number,
h: number,
anchor: { fx: number; fy: number },
) => void;
// ── Transformation (Bewegen/Spiegeln/Drehen) committen ────────────────────
commitTransformOn: (
sel: TransformSelection,
op: TransformOp,
points: Vec2[],
copyMode: CopyMode,
count: number,
) => void;
// ── Wand-Attribute (Object-Info-Panel) ────────────────────────────────────
/** Generischer immutabler Patch auf eine Wand (per ID). */
updateWall: (id: string, patch: Partial<import("../model/types").Wall>) => void;
/**
* Setzt die Gesamtdicke einer EINSCHICHTIGEN Wand. Ansatz: editiert die
* Schichtdicke des Wandtyps. Ist der Wandtyp mehrschichtig oder von mehr als
* dieser einen Wand genutzt, wird ein dedizierter einschichtiger Wandtyp
* (Klon der ersten Schicht) erzeugt und der Wand zugewiesen — so wirkt die
* Dicke nur auf diese Wand und teilt keine Presets versehentlich.
*/
setWallThickness: (wallId: string, thickness: number) => void;
// ── Decken-Editieren ──────────────────────────────────────────────────────
/** Verschiebt EINEN Umriss-Vertex (per Index) einer Decke. */
moveCeilingGrip: (ceilingId: string, index: number, pt: Vec2) => void;
/** Verschiebt eine Decke (ganzer Umriss) um `delta`. */
moveCeilingBy: (ceilingId: string, delta: Vec2) => void;
/** Verschiebt eine Umriss-KANTE (beide Vertices) einer Decke um `delta`. */
moveCeilingEdge: (
ceilingId: string,
aIndex: number,
bIndex: number,
delta: Vec2,
) => void;
/** Generischer immutabler Patch auf eine Decke (per ID). */
updateCeiling: (
id: string,
patch: Partial<import("../model/types").Ceiling>,
) => void;
/** Setzt die Gesamtdicke einer Decke (thickness-Übersteuerung, Meter). */
setCeilingThickness: (ceilingId: string, thickness: number) => void;
// ── Öffnungs-Editieren (Object-Info-Panel) ────────────────────────────────
/** Generischer immutabler Patch auf eine Öffnung (per ID). */
updateOpening: (
id: string,
patch: Partial<import("../model/types").Opening>,
) => void;
// ── Treppen-Editieren ──────────────────────────────────────────────────────
/** Verschiebt eine Treppe (Antritt) um `delta`. */
moveStairBy: (stairId: string, delta: Vec2) => void;
/** Generischer immutabler Patch auf eine Treppe (per ID). */
updateStair: (
id: string,
patch: Partial<import("../model/types").Stair>,
) => void;
// ── Raum-Editieren ──────────────────────────────────────────────────────────
/** Verschiebt einen Raum (ganzer Umriss + optionaler Stempel-Anker) um `delta`. */
moveRoomBy: (roomId: string, delta: Vec2) => void;
/** Verschiebt EINEN Umriss-Vertex (per Index) eines Raums. */
moveRoomGrip: (roomId: string, index: number, pt: Vec2) => void;
/** Verschiebt eine Umriss-KANTE (beide Vertices) eines Raums um `delta`. */
moveRoomEdge: (
roomId: string,
aIndex: number,
bIndex: number,
delta: Vec2,
) => void;
/** Generischer immutabler Patch auf einen Raum (per ID). */
updateRoom: (
id: string,
patch: Partial<import("../model/types").Room>,
) => void;
/** Verschiebt NUR den Stempel-Anker eines Raums um `delta` (Kontur bleibt). */
moveRoomStamp: (roomId: string, delta: Vec2) => void;
/** Setzt den Stempel-Anker eines Raums absolut. */
setRoomStampAnchor: (roomId: string, anchor: Vec2) => void;
/** Setzt den Rich-Text des Raum-Stempels. */
setRoomStampDoc: (
roomId: string,
doc: import("../model/types").RichTextDoc,
) => void;
/** Setzt den strukturierten Raum-Stempel (Feldmodell). */
setRoomStamp: (
roomId: string,
stamp: import("../model/types").RoomStamp,
) => void;
}
/**
* Minimaler View-Ausschnitt, den Projekt-Aktionen lesen/schreiben dürfen
* (Cross-Slice). Der Store komponiert ProjectSlice mit dieser Sicht, sodass
* `get()`/`set` typsicher auf `activeLevelId` zugreifen können.
*/
type ProjectSliceDeps = {
activeLevelId: string;
};
export function createProjectSlice(
api: StoreApi<ProjectSlice & ProjectSliceDeps>,
): ProjectSlice {
const { set, get } = api;
// Bequemer immutabler Projekt-Updater (wie setProject in App).
const setProject: ProjectSlice["setProject"] = (next) =>
set((s) => ({
project: typeof next === "function" ? (next as (p: Project) => Project)(s.project) : next,
}));
return {
project: sampleProject,
setProject,
// ── Zeichnungsebenen ───────────────────────────────────────────────────
toggleLevelVisible: (id) =>
setProject((p) => ({
...p,
drawingLevels: p.drawingLevels.map((z) =>
z.id === id ? { ...z, visible: !z.visible } : z,
),
})),
patchActiveFloor: (patch) => {
const activeLevelId = get().activeLevelId;
setProject((p) => ({
...p,
drawingLevels: recomputeFloorElevations(
p.drawingLevels.map((z) =>
z.id === activeLevelId ? { ...z, ...patch } : z,
),
),
}));
},
addFloor: () =>
setProject((p) => {
const n = p.drawingLevels.filter((z) => z.kind === "floor").length + 1;
const floor: DrawingLevel = {
id: `floor-${Date.now()}`,
name: t("default.floorName", { n }),
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
};
return {
...p,
drawingLevels: recomputeFloorElevations([...p.drawingLevels, floor]),
};
}),
addDrawing: () =>
setProject((p) => {
const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1;
const drawing: DrawingLevel = {
id: `drawing-${Date.now()}`,
name: t("default.drawingName", { n }),
kind: "drawing",
visible: true,
locked: false,
};
return { ...p, drawingLevels: [...p.drawingLevels, drawing] };
}),
// Schnitt/Ansicht sind vorerst Platzhalter ohne Schnittlinie (linePoints
// bleibt undefined, bis die Schnitt-UI existiert — siehe toSection.ts).
addSection: () =>
setProject((p) => {
const n = p.drawingLevels.filter((z) => z.kind === "section").length + 1;
const section: DrawingLevel = {
id: `section-${Date.now()}`,
name: t("default.sectionName", { n }),
kind: "section",
visible: true,
locked: false,
};
return { ...p, drawingLevels: [...p.drawingLevels, section] };
}),
addElevation: () =>
setProject((p) => {
const n = p.drawingLevels.filter((z) => z.kind === "elevation").length + 1;
const elevation: DrawingLevel = {
id: `elevation-${Date.now()}`,
name: t("default.elevationName", { n }),
kind: "elevation",
visible: true,
locked: false,
};
return { ...p, drawingLevels: [...p.drawingLevels, elevation] };
}),
// Neue freie Zeichnungsebene mit explizitem Namen anlegen und ihre ID
// zurückgeben (Ziel-Ebene eines DXF-Imports). Der Name kommt vom Aufrufer
// (Dialog); leere Eingabe fällt auf den Default-Namen zurück.
addDrawingLevel: (name) => {
const id = `drawing-${Date.now()}`;
setProject((p) => {
const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1;
const drawing: DrawingLevel = {
id,
name: name.trim() || t("default.drawingName", { n }),
kind: "drawing",
visible: true,
locked: false,
};
return { ...p, drawingLevels: [...p.drawingLevels, drawing] };
});
return id;
},
importDrawings: (drawings) =>
setProject((p) =>
drawings.length
? { ...p, drawings2d: [...p.drawings2d, ...drawings] }
: p,
),
patchLevel: (id, patch) =>
setProject((p) => ({
...p,
drawingLevels: recomputeFloorElevations(
p.drawingLevels.map((z) => (z.id === id ? { ...z, ...patch } : z)),
),
})),
duplicateLevel: (id) =>
setProject((p) => {
const i = p.drawingLevels.findIndex((z) => z.id === id);
if (i < 0) return p;
const src = p.drawingLevels[i];
const clone: DrawingLevel = {
...src,
id: `${src.kind}-${Date.now()}`,
name: t("default.copySuffixLevel", { name: src.name }),
};
const next = [...p.drawingLevels];
next.splice(i + 1, 0, clone);
return { ...p, drawingLevels: recomputeFloorElevations(next) };
}),
// Zeichnungsebene löschen (≥1 behalten). War es das aktive Geschoss, auf
// die erste verbleibende Ebene wechseln (View-Feld, Cross-Slice).
deleteLevel: (id) => {
const { project, activeLevelId } = get();
if (project.drawingLevels.length <= 1) return; // letzte behalten
if (!project.drawingLevels.some((z) => z.id === id)) return;
if (activeLevelId === id) {
const fallback = project.drawingLevels.find((z) => z.id !== id);
if (fallback) set({ activeLevelId: fallback.id });
}
setProject((p) => {
const removedWallIds = new Set(
p.walls.filter((w) => w.floorId === id).map((w) => w.id),
);
return {
...p,
drawingLevels: recomputeFloorElevations(
p.drawingLevels.filter((z) => z.id !== id),
),
walls: p.walls.filter((w) => w.floorId !== id),
ceilings: (p.ceilings ?? []).filter((c) => c.floorId !== id),
stairs: (p.stairs ?? []).filter((s) => s.floorId !== id),
rooms: (p.rooms ?? []).filter((r) => r.floorId !== id),
doors: p.doors.filter((d) => !removedWallIds.has(d.hostWallId)),
openings: (p.openings ?? []).filter(
(o) => !removedWallIds.has(o.hostWallId),
),
};
});
},
// ── Ebenen (Kategorien) ────────────────────────────────────────────────
toggleCategoryVisible: (code) =>
setProject((p) => ({
...p,
layers: toggleCategoryByCode(p.layers, code),
})),
addCategory: () =>
setProject((p) => {
const code = nextFreeCode(p.layers);
const category: LayerCategory = {
code,
name: t("default.categoryName"),
color: "#888888",
lw: 0.18,
visible: true,
locked: false,
};
return { ...p, layers: [...p.layers, category] };
}),
patchCategory: (code, patch) =>
setProject((p) => ({
...p,
layers: patchCategoryByCode(p.layers, code, patch),
})),
addSubCategory: (parentCode) =>
setProject((p) => {
const code = nextFreeCode(p.layers);
const child: LayerCategory = {
code,
name: t("default.subCategoryName"),
color: "#888888",
lw: 0.18,
visible: true,
locked: false,
};
return { ...p, layers: addChildByCode(p.layers, parentCode, child) };
}),
duplicateCategory: (code) =>
setProject((p) => {
const src = findCategory(p.layers, code);
if (!src) return p;
const clone: LayerCategory = {
...src,
code: nextFreeCode(p.layers),
name: t("default.copySuffixLayer", { name: src.name }),
children: undefined,
};
return { ...p, layers: insertAfterCode(p.layers, code, clone) };
}),
deleteCategory: (code) =>
setProject((p) => {
if (flattenCategories(p.layers).length <= 1) return p; // letzte behalten
const src = findCategory(p.layers, code);
if (!src) return p;
const codes = new Set(flattenCategories([src]).map((c) => c.code));
const usedByWall = p.walls.some((w) => codes.has(w.categoryCode));
const usedByDoor = p.doors.some((d) => codes.has(d.categoryCode));
const usedByOpening = (p.openings ?? []).some((o) =>
codes.has(o.categoryCode),
);
if (usedByWall || usedByDoor || usedByOpening) {
window.alert(t("alert.layerInUse", { code: src.code, name: src.name }));
return p;
}
return { ...p, layers: removeByCode(p.layers, code) };
}),
assignWallCategory: (wallId, code) =>
setProject((p) => ({
...p,
walls: p.walls.map((w) =>
w.id === wallId ? { ...w, categoryCode: code } : w,
),
})),
// ── Sichtbarkeits-Kombinationen ────────────────────────────────────────
snapshotLayerVisibility: () => {
const out: Record<string, boolean> = {};
for (const c of flattenCategories(get().project.layers)) {
out[c.code] = c.visible;
}
return out;
},
applyLayerVisibility: (codes) =>
setProject((p) => ({
...p,
layers: applyVisibilityByCode(p.layers, codes),
})),
snapshotDrawingVisibility: () => {
const out: Record<string, boolean> = {};
for (const z of get().project.drawingLevels) out[z.id] = z.visible;
return out;
},
applyDrawingVisibility: (ids) =>
setProject((p) => ({
...p,
drawingLevels: p.drawingLevels.map((z) =>
z.id in ids ? { ...z, visible: ids[z.id] } : z,
),
})),
// ── Ressourcen ─────────────────────────────────────────────────────────
patchComponent: (id, patch) =>
setProject((p) => ({
...p,
components: p.components.map((c) =>
c.id === id ? { ...c, ...patch } : c,
),
})),
addComponent: () =>
setProject((p) => {
let hatches = p.hatches;
let hatchId = hatches[0]?.id;
if (!hatchId) {
const fallback: HatchStyle = {
id: `hatch-${Date.now()}`,
name: t("default.hatchNone"),
pattern: "none",
scale: 1,
angle: 0,
color: "#2b3039",
};
hatches = [fallback];
hatchId = fallback.id;
}
const component: Component = {
id: `component-${Date.now()}`,
name: t("default.componentName"),
color: "#9aa0a6",
hatchId,
joinPriority: 50,
};
return { ...p, hatches, components: [...p.components, component] };
}),
deleteComponent: (id) =>
setProject((p) => {
const used = p.wallTypes.some((wt) =>
wt.layers.some((l) => l.componentId === id),
);
if (used) {
const name = p.components.find((c) => c.id === id)?.name ?? id;
window.alert(t("alert.componentInUse", { name }));
return p;
}
return { ...p, components: p.components.filter((c) => c.id !== id) };
}),
patchHatch: (id, patch) =>
setProject((p) => ({
...p,
hatches: p.hatches.map((h) => (h.id === id ? { ...h, ...patch } : h)),
})),
addHatch: () =>
setProject((p) => {
const hatch: HatchStyle = {
id: `hatch-${Date.now()}`,
name: t("default.hatchName"),
pattern: "diagonal",
scale: 1,
angle: 45,
color: "#2b3039",
lineStyleId: p.lineStyles[0]?.id,
};
return { ...p, hatches: [...p.hatches, hatch] };
}),
deleteHatch: (id) =>
setProject((p) => {
const used = p.components.some((c) => c.hatchId === id);
if (used) {
const name = p.hatches.find((h) => h.id === id)?.name ?? id;
window.alert(t("alert.hatchInUse", { name }));
return p;
}
return { ...p, hatches: p.hatches.filter((h) => h.id !== id) };
}),
patchLineStyle: (id, patch) =>
setProject((p) => ({
...p,
lineStyles: p.lineStyles.map((l) =>
l.id === id ? { ...l, ...patch } : l,
),
})),
addLineStyle: () =>
setProject((p) => {
const lineStyle: LineStyle = {
id: `line-${Date.now()}`,
name: t("default.lineStyleName"),
weight: 0.18,
color: "#2b3039",
dash: null,
};
return { ...p, lineStyles: [...p.lineStyles, lineStyle] };
}),
deleteLineStyle: (id) =>
setProject((p) => {
const used = p.hatches.some((h) => h.lineStyleId === id);
if (used) {
const name = p.lineStyles.find((l) => l.id === id)?.name ?? id;
window.alert(t("alert.lineStyleInUse", { name }));
return p;
}
return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) };
}),
importLineStyles: (styles) =>
setProject((p) => ({
...p,
lineStyles: [
...p.lineStyles,
...styles.map((s, i) => ({ ...s, id: `ls-${Date.now()}-${i}` })),
],
})),
importHatches: (hatches) =>
setProject((p) => ({
...p,
hatches: [
...p.hatches,
...hatches.map((h, i) => ({ ...h, id: `ht-${Date.now()}-${i}` })),
],
})),
// ── Editier-Griffe ─────────────────────────────────────────────────────
moveGripOf: (drawingId, wallId, index, pt) =>
setProject((p) => moveGrip(p, drawingId, wallId, index, pt)),
moveElementByOf: (drawingId, wallId, delta) =>
setProject((p) => moveElementBy(p, drawingId, wallId, delta)),
moveEdgeOf: (drawingId, wallId, aIndex, bIndex, delta) =>
setProject((p) => moveEdge(p, drawingId, wallId, aIndex, bIndex, delta)),
// ── Selektions-Attribute ───────────────────────────────────────────────
setElementColor: (kind, id, color) =>
setProject((p) => {
if (kind === "drawing2d") {
return {
...p,
drawings2d: p.drawings2d.map((d) =>
d.id === id ? { ...d, color } : d,
),
};
}
return {
...p,
walls: p.walls.map((w) => (w.id === id ? { ...w, color } : w)),
};
}),
// Strichstärke gibt es als direktes Feld NUR auf Drawing2D; bei unbekannter
// ID bleibt das Projekt unverändert (No-op).
setElementWeight: (id, weightMm) =>
setProject((p) => ({
...p,
drawings2d: p.drawings2d.map((d) =>
d.id === id ? { ...d, weightMm } : d,
),
})),
// Füllschraffur NUR für Drawing2D; `null` entfernt die Schraffur (Feld weg).
setElementFill: (id, hatchId) =>
setProject((p) => ({
...p,
drawings2d: p.drawings2d.map((d) => {
if (d.id !== id) return d;
if (hatchId === null) {
const { hatchId: _omit, ...rest } = d;
return rest;
}
return { ...d, hatchId };
}),
})),
// Vollton-Füllfarbe NUR für Drawing2D; `null` entfernt das Feld.
setElementFillColor: (id, fillColor) =>
setProject((p) => ({
...p,
drawings2d: p.drawings2d.map((d) => {
if (d.id !== id) return d;
if (fillColor === null) {
const { fillColor: _omit, ...rest } = d;
return rest;
}
return { ...d, fillColor };
}),
})),
// ── Größe (Resize um Anker) ────────────────────────────────────────────
resizeElement: (kind, id, w, h, anchor) =>
setProject((p) => resizeElement(p, kind, id, w, h, anchor)),
// ── Transformation ─────────────────────────────────────────────────────
commitTransformOn: (sel, op, points, copyMode, count) =>
setProject((p) => commitTransform(p, sel, op, points, copyMode, count)),
// ── Wand-Attribute ─────────────────────────────────────────────────────
updateWall: (id, patch) =>
setProject((p) => ({
...p,
walls: p.walls.map((w) => (w.id === id ? { ...w, ...patch } : w)),
})),
setWallThickness: (wallId, thickness) =>
setProject((p) => setWallThickness(p, wallId, thickness)),
// ── Decken-Editieren ──────────────────────────────────────────────────
moveCeilingGrip: (ceilingId, index, pt) =>
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
...c,
outline: c.outline.map((v, i) => (i === index ? pt : v)),
}))),
moveCeilingBy: (ceilingId, delta) =>
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
...c,
outline: c.outline.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
}))),
moveCeilingEdge: (ceilingId, aIndex, bIndex, delta) =>
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
...c,
outline: c.outline.map((v, i) =>
i === aIndex || i === bIndex ? { x: v.x + delta.x, y: v.y + delta.y } : v,
),
}))),
updateCeiling: (id, patch) =>
setProject((p) => mapCeiling(p, id, (c) => ({ ...c, ...patch }))),
setCeilingThickness: (ceilingId, thickness) =>
setProject((p) => setCeilingThickness(p, ceilingId, thickness)),
// ── Öffnungs-Editieren ────────────────────────────────────────────────
updateOpening: (id, patch) =>
setProject((p) => ({
...p,
openings: (p.openings ?? []).map((o) => (o.id === id ? { ...o, ...patch } : o)),
})),
// ── Treppen-Editieren ──────────────────────────────────────────────────
moveStairBy: (stairId, delta) =>
setProject((p) =>
mapStair(p, stairId, (s) => ({
...s,
start: { x: s.start.x + delta.x, y: s.start.y + delta.y },
...(s.center
? { center: { x: s.center.x + delta.x, y: s.center.y + delta.y } }
: {}),
})),
),
updateStair: (id, patch) =>
setProject((p) => mapStair(p, id, (s) => ({ ...s, ...patch }))),
// ── Raum-Editieren ──────────────────────────────────────────────────────
moveRoomBy: (roomId, delta) =>
setProject((p) =>
mapRoom(p, roomId, (r) => ({
...r,
boundary: r.boundary.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
...(r.stampAnchor
? {
stampAnchor: {
x: r.stampAnchor.x + delta.x,
y: r.stampAnchor.y + delta.y,
},
}
: {}),
})),
),
moveRoomGrip: (roomId, index, pt) =>
setProject((p) =>
mapRoom(p, roomId, (r) => ({
...r,
boundary: r.boundary.map((v, i) => (i === index ? pt : v)),
})),
),
moveRoomEdge: (roomId, aIndex, bIndex, delta) =>
setProject((p) =>
mapRoom(p, roomId, (r) => ({
...r,
boundary: r.boundary.map((v, i) =>
i === aIndex || i === bIndex
? { x: v.x + delta.x, y: v.y + delta.y }
: v,
),
})),
),
updateRoom: (id, patch) =>
setProject((p) => mapRoom(p, id, (r) => ({ ...r, ...patch }))),
moveRoomStamp: (roomId, delta) =>
setProject((p) =>
mapRoom(p, roomId, (r) => {
const base = r.stampAnchor ?? roomCentroid(r.boundary);
return {
...r,
stampAnchor: { x: base.x + delta.x, y: base.y + delta.y },
};
}),
),
setRoomStampAnchor: (roomId, anchor) =>
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampAnchor: anchor }))),
setRoomStampDoc: (roomId, doc) =>
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampDoc: doc }))),
setRoomStamp: (roomId, stamp) =>
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stamp }))),
};
}
/** Immutabler Map über einen Raum (per ID); No-op ohne Treffer. */
function mapRoom(
project: Project,
roomId: string,
fn: (r: import("../model/types").Room) => import("../model/types").Room,
): Project {
const rooms = project.rooms ?? [];
return {
...project,
rooms: rooms.map((r) => (r.id === roomId ? fn(r) : r)),
};
}
/** Immutabler Map über eine Treppe (per ID); No-op ohne Treffer. */
function mapStair(
project: Project,
stairId: string,
fn: (s: import("../model/types").Stair) => import("../model/types").Stair,
): Project {
const stairs = project.stairs ?? [];
return {
...project,
stairs: stairs.map((s) => (s.id === stairId ? fn(s) : s)),
};
}
/** Immutabler Map über eine Decke (per ID); No-op ohne Treffer. */
function mapCeiling(
project: Project,
ceilingId: string,
fn: (c: import("../model/types").Ceiling) => import("../model/types").Ceiling,
): Project {
const ceilings = project.ceilings ?? [];
return {
...project,
ceilings: ceilings.map((c) => (c.id === ceilingId ? fn(c) : c)),
};
}
/**
* Setzt die Gesamtdicke einer einschichtigen Wand über ihre Wandtyp-Schicht.
* Wenn der Wandtyp einschichtig ist UND nur von dieser Wand genutzt wird, wird
* er direkt editiert. Sonst wird ein dedizierter einschichtiger Wandtyp (Klon
* der ersten Schicht des aktuellen Typs) erzeugt und der Wand zugewiesen.
*/
function setWallThickness(
project: Project,
wallId: string,
thickness: number,
): Project {
if (!isFinite(thickness) || thickness <= 0) return project;
const wall = project.walls.find((w) => w.id === wallId);
if (!wall) return project;
const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId);
if (!wt || wt.layers.length === 0) return project;
const single = wt.layers.length === 1;
const usedByOthers = project.walls.some(
(w) => w.id !== wallId && w.wallTypeId === wt.id,
);
if (single && !usedByOthers) {
// Direkt die Schichtdicke des (exklusiven, einschichtigen) Wandtyps setzen.
return {
...project,
wallTypes: project.wallTypes.map((t) =>
t.id === wt.id
? { ...t, layers: [{ ...t.layers[0], thickness }] }
: t,
),
};
}
// Dedizierten einschichtigen Wandtyp erzeugen (erste Schicht klonen) und der
// Wand zuweisen — damit die Dicke nur diese Wand betrifft.
const id = `wt-${Date.now()}`;
const clone = {
id,
name: t("default.wallTypeSingle", { thickness: thickness.toFixed(2) }),
layers: [{ ...wt.layers[0], thickness }],
};
return {
...project,
wallTypes: [...project.wallTypes, clone],
walls: project.walls.map((w) =>
w.id === wallId ? { ...w, wallTypeId: id } : w,
),
};
}
/**
* Setzt die Gesamtdicke einer einschichtigen Decke über ihre Deckentyp-Schicht
* — analog `setWallThickness`. Wenn der aktive Aufbau-Typ einschichtig UND ein
* DEDIZIERTER Deckentyp (`ceilingTypeId`) ist, der nur von dieser Decke genutzt
* wird, wird er direkt editiert. Sonst wird ein neuer, dedizierter
* einschichtiger Deckentyp (Klon der ersten Schicht des aktuellen Aufbaus)
* erzeugt und der Decke zugewiesen. Der geteilte LEGACY-Wandtyp
* (`Ceiling.wallTypeId`) wird dabei NIE mutiert — er gehört den Wänden.
*/
function setCeilingThickness(
project: Project,
ceilingId: string,
thickness: number,
): Project {
if (!isFinite(thickness) || thickness <= 0) return project;
const ceiling = (project.ceilings ?? []).find((c) => c.id === ceilingId);
if (!ceiling) return project;
const activeId = ceiling.ceilingTypeId ?? ceiling.wallTypeId;
const ct =
(project.ceilingTypes ?? []).find((t) => t.id === ceiling.ceilingTypeId) ??
project.wallTypes.find((t) => t.id === ceiling.wallTypeId);
if (!ct || ct.layers.length === 0) return project;
const single = ct.layers.length === 1;
const usedByOthers = (project.ceilings ?? []).some(
(c) => c.id !== ceilingId && (c.ceilingTypeId ?? c.wallTypeId) === activeId,
);
if (single && !usedByOthers && ceiling.ceilingTypeId) {
// Direkt die Schichtdicke des (exklusiven, einschichtigen) Deckentyps setzen.
return {
...project,
ceilingTypes: (project.ceilingTypes ?? []).map((t) =>
t.id === ct.id ? { ...t, layers: [{ ...t.layers[0], thickness }] } : t,
),
};
}
// Dedizierten einschichtigen Deckentyp erzeugen (erste Schicht klonen) und
// der Decke zuweisen — damit die Dicke nur diese Decke betrifft.
const id = `ct-${Date.now()}`;
const clone = {
id,
name: t("default.ceilingTypeSingle", { thickness: thickness.toFixed(2) }),
layers: [{ ...ct.layers[0], thickness }],
};
return {
...project,
ceilingTypes: [...(project.ceilingTypes ?? []), clone],
ceilings: (project.ceilings ?? []).map((c) =>
c.id === ceilingId ? { ...c, ceilingTypeId: id, thickness: undefined } : c,
),
};
}
// ── Resize (Skalierung auf Zielmaß um einen Anker) ───────────────────────────
/**
* Skaliert ein Element auf Zielbreite `w` × -höhe `h` (Meter) um einen
* Ankerpunkt. `anchor.fx/fy ∈ [0,1]` liegen relativ zur aktuellen bbox
* (0,0 = oben-links, 1,1 = unten-rechts). Der Ankerpunkt bleibt fix; alle
* übrigen Punkte werden um die Skalierungsfaktoren um ihn herum gestreckt.
*/
function resizeElement(
project: Project,
kind: "wall" | "drawing2d",
id: string,
w: number,
h: number,
anchor: { fx: number; fy: number },
): Project {
if (kind === "drawing2d") {
return {
...project,
drawings2d: project.drawings2d.map((d) =>
d.id === id ? { ...d, geom: resizeGeom(d.geom, w, h, anchor) } : d,
),
};
}
return {
...project,
walls: project.walls.map((wall) => {
if (wall.id !== id) return wall;
const box = bbox2([wall.start, wall.end]);
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
const apply = (p: Vec2): Vec2 => ({
x: ax + (p.x - ax) * sx,
y: ay + (p.y - ay) * sy,
});
return { ...wall, start: apply(wall.start), end: apply(wall.end) };
}),
};
}
/** bbox einer Punktliste. */
function bbox2(pts: Vec2[]): { minX: number; minY: number; maxX: number; maxY: number } {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
}
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return { minX, minY, maxX, maxY };
}
/**
* Liefert Ankerpunkt (ax,ay) und Skalierungsfaktoren (sx,sy), um eine bbox auf
* Zielbreite `w` × -höhe `h` zu bringen. Bei Null-Ausdehnung in einer Achse
* bleibt der Faktor 1 (keine Division durch 0, keine Verschiebung).
*/
function scaleAround(
box: { minX: number; minY: number; maxX: number; maxY: number },
w: number,
h: number,
anchor: { fx: number; fy: number },
): { ax: number; ay: number; sx: number; sy: number } {
const bw = box.maxX - box.minX;
const bh = box.maxY - box.minY;
const ax = box.minX + anchor.fx * bw;
const ay = box.minY + anchor.fy * bh;
const sx = bw > 1e-9 ? w / bw : 1;
const sy = bh > 1e-9 ? h / bh : 1;
return { ax, ay, sx, sy };
}
/**
* Skaliert eine 2D-Form auf Zielmaß um den Anker.
* • line/polyline/rect: jeder Punkt um den Anker skaliert (B/H aus bbox).
* • circle: Radius = halbe Zielbreite (min/Mittelwert wäre auch denkbar; Breite
* ist eindeutig), Mittelpunkt um den Anker verschoben.
* • arc: wie circle (Radius aus Zielbreite), Winkel unverändert.
* • text: nur die Einfügeposition wird um den Anker verschoben; die Schrifthöhe
* bleibt (Text skaliert über `height`, nicht über bbox — bewusst unverändert).
*/
function resizeGeom(
g: import("../model/types").Drawing2DGeom,
w: number,
h: number,
anchor: { fx: number; fy: number },
): import("../model/types").Drawing2DGeom {
switch (g.shape) {
case "line": {
const { ax, ay, sx, sy } = scaleAround(bbox2([g.a, g.b]), w, h, anchor);
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
return { ...g, a: f(g.a), b: f(g.b) };
}
case "polyline": {
const { ax, ay, sx, sy } = scaleAround(bbox2(g.pts), w, h, anchor);
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
return { ...g, pts: g.pts.map(f) };
}
case "rect": {
const box = bbox2([g.min, g.max]);
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
const a = f(g.min);
const b = f(g.max);
return {
...g,
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
};
}
case "circle":
case "arc": {
// bbox eines Kreises ist 2r×2r; der Anker liegt relativ dazu.
const box = {
minX: g.center.x - g.r,
minY: g.center.y - g.r,
maxX: g.center.x + g.r,
maxY: g.center.y + g.r,
};
const ax = box.minX + anchor.fx * (2 * g.r);
const ay = box.minY + anchor.fy * (2 * g.r);
const r = Math.max(w, h) / 2 > 0 ? Math.max(w, h) / 2 : g.r;
const sx = g.r > 1e-9 ? (w / 2) / g.r : 1;
const sy = g.r > 1e-9 ? (h / 2) / g.r : 1;
// Mittelpunkt anhand der nicht-uniformen Skalierung um den Anker schieben,
// den Radius aber als skalaren Kreis (max der Zielmaße/2) setzen.
const center: Vec2 = {
x: ax + (g.center.x - ax) * sx,
y: ay + (g.center.y - ay) * sy,
};
return { ...g, center, r };
}
case "text": {
// Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt).
const box = { minX: g.at.x, minY: g.at.y, maxX: g.at.x, maxY: g.at.y };
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
return { ...g, at: { x: ax + (g.at.x - ax) * sx, y: ay + (g.at.y - ay) * sy } };
}
default:
return g;
}
}
// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ─────────────────────────────
/**
* Editier-Griffe (Eckpunkte) eines 2D-Zeichenelements in Modell-Metern. Phase 4
* unterstützt line/polyline/rect; circle/arc/text bekommen (noch) keine Griffe.
*/
export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
const g = d.geom;
if (g.shape === "line") return [g.a, g.b];
if (g.shape === "polyline") return [...g.pts];
if (g.shape === "rect") {
return [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
}
return [];
}
/**
* Verschiebt den Griff `index` des selektierten Elements (2D-Element ODER Wand)
* auf `pt` (immutabel). Beim Rechteck bleibt die Achsparallelität erhalten.
*/
function moveGrip(
project: Project,
drawingId: string | null,
wallId: string | null,
index: number,
pt: Vec2,
): Project {
if (drawingId) {
return {
...project,
drawings2d: project.drawings2d.map((d) => {
if (d.id !== drawingId) return d;
const g = d.geom;
if (g.shape === "line") {
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
}
if (g.shape === "polyline") {
const pts = g.pts.map((p, i) => (i === index ? pt : p));
return { ...d, geom: { ...g, pts } };
}
if (g.shape === "rect") {
const corners = [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
const opp = corners[(index + 2) % 4];
const min = { x: Math.min(pt.x, opp.x), y: Math.min(pt.y, opp.y) };
const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) };
return { ...d, geom: { ...g, min, max } };
}
return d;
}),
};
}
if (wallId) {
return {
...project,
walls: project.walls.map((w) =>
w.id === wallId ? { ...w, ...(index === 0 ? { start: pt } : { end: pt }) } : w,
),
};
}
return project;
}
/** Verschiebt das selektierte Element (2D-Element ODER Wand) um `delta` (parallel). */
function moveElementBy(
project: Project,
drawingId: string | null,
wallId: string | null,
delta: Vec2,
): Project {
const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y });
if (drawingId) {
return {
...project,
drawings2d: project.drawings2d.map((d) => {
if (d.id !== drawingId) return d;
const g = d.geom;
switch (g.shape) {
case "line":
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
case "polyline":
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "rect":
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
case "circle":
case "arc":
return { ...d, geom: { ...g, center: mv(g.center) } };
case "text":
return { ...d, geom: { ...g, at: mv(g.at) } };
default:
return d;
}
}),
};
}
if (wallId) {
return {
...project,
walls: project.walls.map((w) =>
w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w,
),
};
}
return project;
}
/**
* Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der
* Kante um `delta` (immutabel). Für 2D-Elemente:
* • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu
* normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite.
* • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben.
* • line: beide Endpunkte verschieben (das gesamte Segment senkrecht).
* Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse).
*/
function moveEdge(
project: Project,
drawingId: string | null,
wallId: string | null,
aIndex: number,
bIndex: number,
delta: Vec2,
): Project {
const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y });
if (drawingId) {
return {
...project,
drawings2d: project.drawings2d.map((d) => {
if (d.id !== drawingId) return d;
const g = d.geom;
if (g.shape === "line") {
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
}
if (g.shape === "polyline") {
const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p));
return { ...d, geom: { ...g, pts } };
}
if (g.shape === "rect") {
// Ecken in derselben Reihenfolge wie drawingVertices, die zwei Kanten-
// Ecken verschieben, dann bbox neu bilden (achsparallel halten).
const corners = [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
const moved = corners.map((c, i) =>
i === aIndex || i === bIndex ? mv(c) : c,
);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const c of moved) {
minX = Math.min(minX, c.x); minY = Math.min(minY, c.y);
maxX = Math.max(maxX, c.x); maxY = Math.max(maxY, c.y);
}
return { ...d, geom: { ...g, min: { x: minX, y: minY }, max: { x: maxX, y: maxY } } };
}
return d;
}),
};
}
if (wallId) {
// Wand: beide Enden gemeinsam → ganze Wand senkrecht verschieben.
return {
...project,
walls: project.walls.map((w) =>
w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w,
),
};
}
return project;
}
// ── Immutable Tree-Helfer (verschoben aus App.tsx, 1:1) ──────────────────────
/** Schaltet die Sichtbarkeit der Kategorie mit `code` im Baum um (immutabel). */
export function toggleCategoryByCode(
cats: LayerCategory[],
code: string,
): LayerCategory[] {
return cats.map((c) => {
if (c.code === code) return { ...c, visible: !c.visible };
if (c.children)
return { ...c, children: toggleCategoryByCode(c.children, code) };
return c;
});
}
/**
* Setzt im Baum die `visible`-Flags gemäß `codes` (Code → sichtbar?, immutabel).
* Kategorien, deren Code nicht in der Map steht, bleiben unverändert; Codes der
* Map, die es nicht mehr gibt, werden stillschweigend ignoriert.
*/
export function applyVisibilityByCode(
cats: LayerCategory[],
codes: Record<string, boolean>,
): LayerCategory[] {
return cats.map((c) => {
const next = c.code in codes ? { ...c, visible: codes[c.code] } : c;
if (next.children)
return { ...next, children: applyVisibilityByCode(next.children, codes) };
return next;
});
}
/** Nächster freier zweistelliger Code über den ganzen Baum hinweg. */
export function nextFreeCode(cats: LayerCategory[]): string {
const used = new Set<string>();
const walk = (list: LayerCategory[]) => {
for (const c of list) {
used.add(c.code);
if (c.children) walk(c.children);
}
};
walk(cats);
const nums = [...used].map((c) => parseInt(c, 10)).filter((n) => !isNaN(n));
const start = nums.length ? Math.max(...nums) : 0;
for (let i = start + 1; i < 1000; i++) {
const c = String(i).padStart(2, "0");
if (!used.has(c)) return c;
}
return String(Date.now());
}
/** Findet eine Kategorie (per Code) im Baum oder `undefined`. */
export function findCategory(
cats: LayerCategory[],
code: string,
): LayerCategory | undefined {
for (const c of cats) {
if (c.code === code) return c;
if (c.children) {
const hit = findCategory(c.children, code);
if (hit) return hit;
}
}
return undefined;
}
/** Patcht die Kategorie mit `code` im Baum (immutabel). */
function patchCategoryByCode(
cats: LayerCategory[],
code: string,
patch: Partial<LayerCategory>,
): LayerCategory[] {
return cats.map((c) => {
if (c.code === code) return { ...c, ...patch };
if (c.children)
return { ...c, children: patchCategoryByCode(c.children, code, patch) };
return c;
});
}
/** Hängt `child` als letztes Kind an die Kategorie mit `parentCode` (immutabel). */
function addChildByCode(
cats: LayerCategory[],
parentCode: string,
child: LayerCategory,
): LayerCategory[] {
return cats.map((c) => {
if (c.code === parentCode)
return { ...c, children: [...(c.children ?? []), child] };
if (c.children)
return { ...c, children: addChildByCode(c.children, parentCode, child) };
return c;
});
}
/** Fügt `clone` direkt nach der Kategorie mit `code` auf derselben Ebene ein. */
function insertAfterCode(
cats: LayerCategory[],
code: string,
clone: LayerCategory,
): LayerCategory[] {
const out: LayerCategory[] = [];
for (const c of cats) {
const next =
c.children && !c.children.some((k) => k.code === code)
? { ...c, children: insertAfterCode(c.children, code, clone) }
: c;
out.push(next);
if (c.code === code) out.push(clone);
}
return out;
}
/** Entfernt die Kategorie mit `code` (samt Kindern) aus dem Baum (immutabel). */
function removeByCode(cats: LayerCategory[], code: string): LayerCategory[] {
const out: LayerCategory[] = [];
for (const c of cats) {
if (c.code === code) continue;
out.push(c.children ? { ...c, children: removeByCode(c.children, code) } : c);
}
return out;
}
/** Beschriftung einer Kategorie für die Status-Leiste: „Code Name". */
export function categoryLabel(cats: LayerCategory[], code: string): string {
const c = findCategory(cats, code);
return c ? `${c.code} ${c.name}` : code;
}