Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eb6a99025 | |||
| 7feed9e9e3 |
+4
-3
@@ -632,7 +632,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
|
||||
## Phase G — Layouts & Plot
|
||||
- [x] G1 Datenmodell layouts (Yjs Map: layoutId→{name, viewport{center,scale}, frameBlockRef}), Backend persistence folgt automatisch via drawing json *(2026-08-29: LayoutDefinition/LayoutViewport in cad.types, layouts-Map in YjsDocument (YjsDocumentData erweitert), CADDocument-CRUD getLayout/getAllLayouts/addLayout/updateLayout (shallow-merge)/deleteLayout, UndoManager um layouts getrackt; Log G1)*
|
||||
- [ ] G2 LayoutBar UI + Viewport-Widget (zeigt Modellbereich skaliert, klickbar zoom-to)
|
||||
- [x] G2 LayoutBar UI + Viewport-Widget (zeigt Modellbereich skaliert, klickbar zoom-to) *(2026-08-29: LayoutBar-Komponente mit Minimap-Widget (computeMinimapRects, Modell-BBox + Viewport-Rechteck), ZoomPanController.setView(center,scale), CanvasArea-__v2LayoutBridge, App-Mount; Log G2)*
|
||||
- [ ] G3 Titelblock-Block mit Attributen (Projekt/Datum/Maßstab/Autor) auto-insert
|
||||
- [ ] G4 PDF-Export je Layout im Ausgabemaßstab (pdf-lib; Linienbreiten aus Layern; monochrome Option)
|
||||
- [ ] G5 Browser-Print stylesheet (@media print: nur Layout)
|
||||
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-29 | G2 | LayoutBar UI + Viewport-Widget: (1) ZoomPanController.setView(center, scale) NEU — Welt-Punkt exakt ins Canvas-Zentrum bei Scale, geclamped 0.01–100; (2) LayoutBar-Komponente: listet Layouts des aktiven Docs (documentService-Fallback mit onActiveDocumentChanged-Subscription + onChanged-Live-Reload), je Eintrag SVG-Minimap (Modellbereich als Rechteck + Viewport-Ausschnitt blau — scale-abhängig sichtbar kleiner), Klick = zoom-to via Bridge, „+“ speichert aktuellen Viewport als Layout, 🗑 löscht; (3) computeMinimapRects als reine exportierte Funktion (Modell-BBox füllt Widget mit 6px Padding, ohne Elemente Volles-Widget-Fallback, Viewport = 800×600/scale nominal); (4) CanvasArea-__v2LayoutBridge nach E6b-Muster (zoomTo mit render-Trigger, getView aus getViewport+getScale); (5) App.tsx mountet LayoutBar über StatusBar; CSS angehängt; Zwischenfälle selbst gefixt: view-Parameter-Kollision in computeMinimapRects (Umbenennung viewRect) + Regex-Double-Escape im Test | Frontend: 568/568 Tests (+11 layoutBar.test.tsx), tsc 0, Build EXIT 0 | 7feed9e | G2 COMPLETE; nächster Task G3 (Titelblock-Block mit Attributen auto-insert) |
|
||||
| 2026-08-29 | G1 | Layout-Datenmodell: LayoutViewport {center{x,y}, scale} + LayoutDefinition {id, name, viewport, frameBlockRef?} in cad.types.ts; YjsDocument.data um layouts-Map (YjsDocumentData-Interface additiv erweitert + getLayouts-Getter); CADDocument um Layout-CRUD erweitert: getLayout/getAllLayouts/addLayout/updateLayout (Top-Level ersetzt, viewport + center SHALLOW gemergt — center/scale einzeln änderbar, frameBlockRef bleibt bei reinem name-Patch erhalten)/deleteLayout (still bei unbekannter ID); Y.UndoManager um layouts-Map getrackt → jedes add/update/delete = eigener Undo-Schritt (2-Schritte-Undo-Test: add→delete→undo→undo verifiziert); Persistence läuft automatisch über bestehende drawing-json-Serialisierung (Y-Doc-Snapshot) | Frontend: 557/557 Tests (+6 layouts.test.ts), tsc 0, Build EXIT 0 | e750a45 | G1 COMPLETE — Datenmodell steht; nächster Task G2 (LayoutBar UI + Viewport-Widget) |
|
||||
| 2026-08-29 | E7 | Event-Elemente-Polish: (1) ElementType um curtain/spotlight/barrier erweitert (cad.types.ts additiv); (2) Pixi-Drawer-Cases: curtain = wellige Polyline aus properties.points (24 Punkte, Sinus gedämpft, close=false; Fallback Linie), spotlight = Kegel-Polygon aus points (close=true, stroke=properties.color), barrier = Kreis-Paare aus properties.circles (12 Kreise, Fallback 5 entlang BBox); (3) Platzier-Tools via Klick: curtainTool (stageHeight-Option, Standard 120cm, Breite 400), spotlightTool (angle+color-Optionen, Länge 300, Öffnung ±20°, Grad-Winkel direkt — Konvention konsistent zu F6), barrierTool (length-Option, Standard 200, 6 Kettenglieder); (4) createStage um properties.height (Bühnenhöhe in cm) erweitert; event-tools v2.2.0, tools-Array jetzt 13 Einträge; Test-Helper fireDown auf etabliertes Pilot-Muster umgestellt (echtes CADDocument aus createInMemoryDoc statt Broken-Mock ohne transact — 7 Tests rot wegen Mock, nach Muster-Fix grün) | Frontend: 551/551 Tests (+8 eventPolish.test.ts), tsc 0, build OK | 9176d7c | **E7 COMPLETE — PHASE E VOLLSTÄNDIG**; nächster Schritt: Phase G (Layouts & Plot) ab G1 |
|
||||
| 2026-08-29 | D-VERIF | Phase-D-Checklisten-Diskrepanz geklärt: D5 (stretchTool), D6 (breakTool+joinTool), D7 (chamferTool/lengthenTool/explodeTool/alignTool), D8 (polylineEditTool) gegen Code verifiziert und abgehakt — alle im core-modify tools-Array (22 Tools) registriert, Log-Einträge D-fin/D-ext/D-ext3 dokumentieren Implementierung mit grünen Tests; D10 angepasst: array-rect/polar erledigt, array-path + Dialoge bleiben offen; tatsächlich noch offen: D1 (Ellipse-Tool), D2 (Spline-Tool), D3 (Point/XLine/Ray), D4 (MTEXT-Tool), D9 (GripEditing), D10-Rest (array-path), D11 (Snaps), D12 (BlockAttribute) | — (reine Verifikation/Doku, kein Code geändert) | — | Kein Commit nötig — ROADMAP-Doku |
|
||||
@@ -733,5 +734,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||
|
||||
---
|
||||
*Nächster offener Schritt: **G2** (LayoutBar UI + Viewport-Widget: zeigt Modellbereich skaliert, klickbar zoom-to). Danach: G3 Titelblock, G4 PDF-Export, G5 Print-Stylesheet.*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F komplett, G1 fertig (Layout-Datenmodell), BUG-1-Kollisionsklasse geschlossen. FE 557/557 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
*Nächster offener Schritt: **G3** (Titelblock-Block mit Attributen Projekt/Datum/Maßstab/Autor, auto-insert). Danach: G4 PDF-Export je Layout, G5 Print-Stylesheet.*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F komplett, G1+G2 fertig (Layout-Datenmodell + LayoutBar), BUG-1-Kollisionsklasse geschlossen. FE 568/568 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
|
||||
@@ -15,6 +15,7 @@ import CanvasArea from './components/CanvasArea';
|
||||
import RightSidebar from './components/RightSidebar';
|
||||
import CommandLine from './components/CommandLine';
|
||||
import StatusBar from './components/StatusBar';
|
||||
import LayoutBar from './components/LayoutBar';
|
||||
import MobileDrawers from './components/MobileDrawers';
|
||||
import BackgroundImport from './components/BackgroundImport';
|
||||
import HistoryPanel from './components/HistoryPanel';
|
||||
@@ -1704,6 +1705,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
history={commandHistory}
|
||||
onCommand={handleCommand}
|
||||
/>
|
||||
<LayoutBar />
|
||||
<StatusBar
|
||||
snapEnabled={snapEnabled}
|
||||
orthoEnabled={orthoEnabled}
|
||||
|
||||
@@ -60,6 +60,17 @@ export class ZoomPanController {
|
||||
this.offsetY = -minY * this.scale + padding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Absolute Ansicht: Welt-Punkt `center` landet exakt im
|
||||
* Canvas-Zentrum bei `scale` (Task G2 — Layout zoom-to).
|
||||
*/
|
||||
setView(center: { x: number; y: number }, scale: number): void {
|
||||
const s = Math.max(0.01, Math.min(100, scale));
|
||||
this.scale = s;
|
||||
this.offsetX = this.canvas.width / 2 - center.x * s;
|
||||
this.offsetY = this.canvas.height / 2 - center.y * s;
|
||||
}
|
||||
|
||||
zoomToRect(rect: { minX: number; minY: number; maxX: number; maxY: number }): void {
|
||||
const padding = 20;
|
||||
const w = rect.maxX - rect.minX;
|
||||
|
||||
@@ -155,6 +155,21 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Task G2: Layout-Bridge für LayoutBar (zoom-to + Viewport-Save) ──
|
||||
(window as unknown as { __v2LayoutBridge: unknown }).__v2LayoutBridge = {
|
||||
zoomTo: (center: { x: number; y: number }, scale: number) => {
|
||||
zoomPan.setView(center, scale);
|
||||
renderEngineRef.current?.render();
|
||||
},
|
||||
getView: () => {
|
||||
const vp = zoomPan.getViewport();
|
||||
return {
|
||||
center: { x: (vp.minX + vp.maxX) / 2, y: (vp.minY + vp.maxY) / 2 },
|
||||
scale: zoomPan.getScale(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Task E6b: Selection-Bridge für GuestPanel (Stuhl-Zuweisung) ──
|
||||
(window as unknown as { __v2GetSelection: unknown }).__v2GetSelection = () => {
|
||||
const ids = Array.from(selectionEngine.getSelectedIds());
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Task G2 – LayoutBar: Layout-Verwaltung + Viewport-Minimap-Widget.
|
||||
*
|
||||
* - Listet Layouts des aktiven Dokuments (documentService-Fallback)
|
||||
* - Klick auf Eintrag = zoom-to gespeichertem Viewport (Bridge)
|
||||
* - „+“ speichert aktuellen Viewport als neues Layout
|
||||
* - „🗑“ löscht Layout; je Eintrag Minimap (Modellbereich + Viewport-Rechteck)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { LayoutDefinition } from '../types/cad.types';
|
||||
import { getActiveDocument, onActiveDocumentChanged } from '../kernel/document/documentService';
|
||||
|
||||
export interface LayoutView {
|
||||
center: { x: number; y: number };
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface LayoutBridge {
|
||||
zoomTo(center: { x: number; y: number }, scale: number): void;
|
||||
getView(): LayoutView;
|
||||
}
|
||||
|
||||
interface LayoutBarProps {
|
||||
/** Aktives Dokument (Fallback: documentService, Task G2 UI-Unabhängigkeit). */
|
||||
doc?: {
|
||||
getAllLayouts(): LayoutDefinition[];
|
||||
addLayout(l: LayoutDefinition): void;
|
||||
deleteLayout(id: string): void;
|
||||
getAllElements?(): Array<{ x: number; y: number; width: number; height: number }>;
|
||||
onChanged?(cb: () => void): () => void;
|
||||
} | null;
|
||||
/** Canvas-Bridge (Fallback: window.__v2LayoutBridge aus CanvasArea). */
|
||||
bridge?: LayoutBridge | null;
|
||||
}
|
||||
|
||||
interface Rect { x: number; y: number; w: number; h: number }
|
||||
|
||||
/**
|
||||
* Minimap-Geometrie (Task G2, rein & getestet):
|
||||
* Modell-BBox füllt das Widget (Stretch mit 6px Padding); ohne Elemente
|
||||
* Volles Widget. Viewport-Rechteck = nominale Ansicht 800×600/scale Welt.
|
||||
*/
|
||||
export function computeMinimapRects(
|
||||
elements: Array<{ x: number; y: number; width: number; height: number }>,
|
||||
view: LayoutView,
|
||||
widgetW: number,
|
||||
widgetH: number,
|
||||
): { model: Rect; view: Rect } {
|
||||
if (!Array.isArray(elements) || elements.length === 0) {
|
||||
return { model: { x: 0, y: 0, w: widgetW, h: widgetH }, view: { x: 0, y: 0, w: widgetW, h: widgetH } };
|
||||
}
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of elements) {
|
||||
minX = Math.min(minX, el.x - el.width / 2);
|
||||
minY = Math.min(minY, el.y - el.height / 2);
|
||||
maxX = Math.max(maxX, el.x + el.width / 2);
|
||||
maxY = Math.max(maxY, el.y + el.height / 2);
|
||||
}
|
||||
if (!isFinite(minX) || maxX - minX <= 0 || maxY - minY <= 0) {
|
||||
return { model: { x: 0, y: 0, w: widgetW, h: widgetH }, view: { x: 0, y: 0, w: widgetW, h: widgetH } };
|
||||
}
|
||||
const PAD = 6;
|
||||
const model: Rect = { x: PAD, y: PAD, w: widgetW - 2 * PAD, h: widgetH - 2 * PAD };
|
||||
const wx = (w: number) => model.x + ((w - minX) / (maxX - minX)) * model.w;
|
||||
const wy = (w: number) => model.y + ((w - minY) / (maxY - minY)) * model.h;
|
||||
const viewW = 800 / view.scale;
|
||||
const viewH = 600 / view.scale;
|
||||
const viewRect: Rect = {
|
||||
x: wx(view.center.x - viewW / 2),
|
||||
y: wy(view.center.y - viewH / 2),
|
||||
w: (viewW / (maxX - minX)) * model.w,
|
||||
h: (viewH / (maxY - minY)) * model.h,
|
||||
};
|
||||
return { model, view: viewRect };
|
||||
}
|
||||
|
||||
const WIDGET_W = 100;
|
||||
const WIDGET_H = 80;
|
||||
|
||||
const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp }) => {
|
||||
const [doc, setDoc] = useState(docProp ?? getActiveDocument());
|
||||
const [layouts, setLayouts] = useState<LayoutDefinition[]>([]);
|
||||
|
||||
const bridge: LayoutBridge | null = bridgeProp ??
|
||||
((typeof window !== 'undefined' ? (window as unknown as { __v2LayoutBridge?: LayoutBridge }).__v2LayoutBridge : undefined) ?? null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLayouts(doc ? doc.getAllLayouts() : []);
|
||||
}, [doc]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
// Live-Updates (auch Remote via CRDT), falls das Doc es anbietet
|
||||
const off = doc?.onChanged?.(reload);
|
||||
return () => { if (off) off(); };
|
||||
}, [reload]);
|
||||
|
||||
// Fallback-Verdrahtung: aktives Dokument aus dem documentService
|
||||
useEffect(() => {
|
||||
if (docProp) return;
|
||||
setDoc(getActiveDocument());
|
||||
const off = onActiveDocumentChanged(() => setDoc(getActiveDocument()));
|
||||
return off;
|
||||
}, [docProp]);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!bridge || !doc) return;
|
||||
const view = bridge.getView();
|
||||
doc.addLayout({
|
||||
id: `layout-${Date.now()}`,
|
||||
name: `Layout ${layouts.length + 1}`,
|
||||
viewport: { center: { ...view.center }, scale: view.scale },
|
||||
});
|
||||
reload();
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
doc?.deleteLayout(id);
|
||||
reload();
|
||||
};
|
||||
|
||||
const handleZoomTo = (l: LayoutDefinition) => {
|
||||
bridge?.zoomTo(l.viewport.center, l.viewport.scale);
|
||||
};
|
||||
|
||||
const elements = doc?.getAllElements?.() ?? [];
|
||||
|
||||
return (
|
||||
<div className="layout-bar" data-testid="layout-bar">
|
||||
<div className="layout-bar-header">
|
||||
<span className="layout-bar-title">Layouts</span>
|
||||
<button
|
||||
type="button"
|
||||
className="layout-bar-add"
|
||||
title="Layout hinzufügen"
|
||||
onClick={handleAdd}
|
||||
disabled={!bridge || !doc}
|
||||
>+</button>
|
||||
</div>
|
||||
{layouts.length === 0 ? (
|
||||
<div className="layout-bar-empty">Keine Layouts</div>
|
||||
) : (
|
||||
<div className="layout-bar-list">
|
||||
{layouts.map((l) => {
|
||||
const rects = computeMinimapRects(elements, l.viewport, WIDGET_W, WIDGET_H);
|
||||
return (
|
||||
<div key={l.id} className="layout-bar-item">
|
||||
<button
|
||||
type="button"
|
||||
className="layout-bar-entry"
|
||||
data-testid={`layout-entry-${l.id}`}
|
||||
title={`Zoom zu ${l.name}`}
|
||||
onClick={() => handleZoomTo(l)}
|
||||
>
|
||||
<svg
|
||||
data-testid={`layout-minimap-${l.id}`}
|
||||
width={WIDGET_W}
|
||||
height={WIDGET_H}
|
||||
viewBox={`0 0 ${WIDGET_W} ${WIDGET_H}`}
|
||||
className="layout-minimap"
|
||||
>
|
||||
<rect
|
||||
x={rects.model.x} y={rects.model.y}
|
||||
width={rects.model.w} height={rects.model.h}
|
||||
fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.25)" strokeWidth="1"
|
||||
/>
|
||||
<rect
|
||||
x={rects.view.x} y={rects.view.y}
|
||||
width={Math.max(2, rects.view.w)} height={Math.max(2, rects.view.h)}
|
||||
fill="rgba(74,144,217,0.25)" stroke="#4a90d9" strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
<span className="layout-bar-name">{l.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="layout-bar-del"
|
||||
title={`Layout ${l.name} löschen`}
|
||||
onClick={() => handleDelete(l.id)}
|
||||
>🗑</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutBar;
|
||||
@@ -2835,3 +2835,82 @@ body.drawer-open { overflow: hidden; }
|
||||
}
|
||||
.lprov-block:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); }
|
||||
.lprov-block .tree-thumb svg { width: 44px; height: 36px; display: block; }
|
||||
|
||||
|
||||
/* ─── Task G2 – LayoutBar ─── */
|
||||
.layout-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(20, 22, 26, 0.6);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.layout-bar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.layout-bar-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #9aa0a6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.layout-bar-add {
|
||||
background: transparent;
|
||||
color: #4a90d9;
|
||||
border: 1px solid rgba(74, 144, 217, 0.4);
|
||||
border-radius: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.layout-bar-add:disabled { opacity: 0.4; cursor: default; }
|
||||
.layout-bar-add:not(:disabled):hover { background: rgba(74, 144, 217, 0.15); }
|
||||
.layout-bar-empty {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.layout-bar-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
.layout-bar-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
.layout-bar-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
.layout-bar-entry:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); }
|
||||
.layout-minimap { border-radius: 4px; background: rgba(0, 0, 0, 0.25); }
|
||||
.layout-bar-name { font-size: 10px; color: #9aa0a6; }
|
||||
.layout-bar-del {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.layout-bar-del:hover { color: #c0392b; background: rgba(192, 57, 43, 0.15); }
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Task G2 – LayoutBar UI + Viewport-Widget + ZoomPanController.setView.
|
||||
*
|
||||
* LayoutBar listet Layouts des aktiven Dokuments, zeigt je Layout ein
|
||||
* Minimap-Widget (Modellbereich + Viewport-Rechteck), Klick = zoom-to,
|
||||
* „+“ speichert aktuellen Viewport als neues Layout, Löschen entfernt.
|
||||
* ZoomPanController.setView(center, scale) setzt die Absolute Ansicht.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import LayoutBar, { computeMinimapRects } from '../src/components/LayoutBar';
|
||||
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||
import type { LayoutDefinition } from '../src/types/cad.types';
|
||||
|
||||
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function fakeDoc(layouts: LayoutDefinition[]) {
|
||||
return {
|
||||
getAllLayouts: () => [...layouts],
|
||||
addLayout: vi.fn((l: LayoutDefinition) => { layouts.push(l); }),
|
||||
deleteLayout: vi.fn((id: string) => {
|
||||
const i = layouts.findIndex((l) => l.id === id);
|
||||
if (i >= 0) layouts.splice(i, 1);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBridge(view = { center: { x: 10, y: 20 }, scale: 2 }) {
|
||||
return {
|
||||
zoomTo: vi.fn(),
|
||||
getView: vi.fn(() => view),
|
||||
};
|
||||
}
|
||||
|
||||
const L1: LayoutDefinition = {
|
||||
id: 'layout-1', name: 'A4 Plan',
|
||||
viewport: { center: { x: 100, y: 50 }, scale: 0.5 },
|
||||
};
|
||||
|
||||
// ─── ZoomPanController.setView ─────────────────────────────
|
||||
|
||||
describe('G2: ZoomPanController.setView', () => {
|
||||
it('setView(center, scale) zentriert Welt-Punkt exakt, Scale direkt', () => {
|
||||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||
zpc.setView({ x: 100, y: 50 }, 2);
|
||||
expect(zpc.getScale()).toBe(2);
|
||||
const vp = zpc.getViewport();
|
||||
const cx = (vp.minX + vp.maxX) / 2;
|
||||
const cy = (vp.minY + vp.maxY) / 2;
|
||||
expect(cx).toBeCloseTo(100, 5);
|
||||
expect(cy).toBeCloseTo(50, 5);
|
||||
});
|
||||
|
||||
it('setView mit scale 1 bei Origin deckt (0,0) als Zentrum ab', () => {
|
||||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||
zpc.setView({ x: 0, y: 0 }, 1);
|
||||
const vp = zpc.getViewport();
|
||||
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||||
expect((vp.minY + vp.maxY) / 2).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('zweiter setView-Aufruf überschreibt ersten komplett', () => {
|
||||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||
zpc.setView({ x: 500, y: 500 }, 4);
|
||||
zpc.setView({ x: 0, y: 0 }, 0.5);
|
||||
expect(zpc.getScale()).toBe(0.5);
|
||||
const vp = zpc.getViewport();
|
||||
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeMinimapRects (Viewport-Widget-Geometrie) ───────
|
||||
|
||||
describe('G2: computeMinimapRects', () => {
|
||||
const elements = [
|
||||
{ x: 50, y: 50, width: 100, height: 100 }, // bbox 0..100
|
||||
];
|
||||
|
||||
it('Modellbbox füllt Widget (mit Padding), leer → Volles Widget', () => {
|
||||
const r = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||||
expect(r.model.w).toBeGreaterThan(80);
|
||||
expect(r.model.h).toBeGreaterThan(60);
|
||||
const empty = computeMinimapRects([], { center: { x: 0, y: 0 }, scale: 1 }, 100, 80);
|
||||
expect(empty.model.w).toBe(100);
|
||||
expect(empty.model.h).toBe(80);
|
||||
});
|
||||
|
||||
it('Viewport-Zentrum liegt im View-Rechteck; höherer Scale = kleinerer Ausschnitt', () => {
|
||||
const r1 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||||
const cx = r1.model.x + (r1.view.x + r1.view.w / 2 - r1.model.x) / r1.model.w * r1.model.w;
|
||||
// View-Zentrum in Widget-Koordinaten:
|
||||
const viewCX = r1.view.x + r1.view.w / 2;
|
||||
const modelCX = r1.model.x + r1.model.w / 2;
|
||||
expect(Math.abs(viewCX - modelCX)).toBeLessThan(1); // 50,50 = BBox-Zentrum
|
||||
const r2 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 4 }, 100, 80);
|
||||
expect(r2.view.w).toBeLessThan(r1.view.w);
|
||||
expect(r2.view.h).toBeLessThan(r1.view.h);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LayoutBar UI ──────────────────────────────────────────
|
||||
|
||||
describe('G2: LayoutBar', () => {
|
||||
it('ohne Doc: Empty-State ohne Crash', () => {
|
||||
render(<LayoutBar doc={null} bridge={null} />);
|
||||
expect(screen.getByText(/Keine Layouts/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('listet Layouts mit Name + Minimap-SVG', () => {
|
||||
const doc = fakeDoc([L1]);
|
||||
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||||
expect(screen.getByText('A4 Plan')).toBeTruthy();
|
||||
expect(screen.getByTestId('layout-minimap-layout-1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Klick auf Layout-Eintrag ruft bridge.zoomTo mit gespeichertem Viewport', () => {
|
||||
const bridge = fakeBridge();
|
||||
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={bridge as any} />);
|
||||
fireEvent.click(screen.getByTestId('layout-entry-layout-1'));
|
||||
expect(bridge.zoomTo).toHaveBeenCalledWith(
|
||||
{ x: 100, y: 50 },
|
||||
0.5,
|
||||
);
|
||||
});
|
||||
|
||||
it('+ Button speichert aktuellen Viewport als neues Layout', () => {
|
||||
const layouts: LayoutDefinition[] = [];
|
||||
const doc = fakeDoc(layouts);
|
||||
const bridge = fakeBridge();
|
||||
render(<LayoutBar doc={doc as any} bridge={bridge as any} />);
|
||||
fireEvent.click(screen.getByTitle('Layout hinzufügen'));
|
||||
expect(doc.addLayout).toHaveBeenCalledTimes(1);
|
||||
const added = (doc.addLayout as ReturnType<typeof vi.fn>).mock.calls[0][0] as LayoutDefinition;
|
||||
expect(added.viewport.center.x).toBe(10);
|
||||
expect(added.viewport.center.y).toBe(20);
|
||||
expect(added.viewport.scale).toBe(2);
|
||||
expect(added.name).toMatch(/Layout \d/);
|
||||
expect(added.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Löschen-Button entfernt Layout über doc.deleteLayout', () => {
|
||||
const doc = fakeDoc([L1]);
|
||||
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||||
fireEvent.click(screen.getByTitle('Layout A4 Plan löschen'));
|
||||
expect(doc.deleteLayout).toHaveBeenCalledWith('layout-1');
|
||||
});
|
||||
|
||||
it('ohne Bridge: Klick wirft nicht (no-op)', () => {
|
||||
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={null} />);
|
||||
expect(() => fireEvent.click(screen.getByTestId('layout-entry-layout-1'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user