diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0aa493a..37532e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = ({ projectId, token, onNavigateBack history={commandHistory} onCommand={handleCommand} /> + = ({ }), }); + // ── 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()); diff --git a/frontend/src/components/LayoutBar.tsx b/frontend/src/components/LayoutBar.tsx new file mode 100644 index 0000000..59d33fd --- /dev/null +++ b/frontend/src/components/LayoutBar.tsx @@ -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 = ({ doc: docProp, bridge: bridgeProp }) => { + const [doc, setDoc] = useState(docProp ?? getActiveDocument()); + const [layouts, setLayouts] = useState([]); + + 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 ( +
+
+ Layouts + +
+ {layouts.length === 0 ? ( +
Keine Layouts
+ ) : ( +
+ {layouts.map((l) => { + const rects = computeMinimapRects(elements, l.viewport, WIDGET_W, WIDGET_H); + return ( +
+ + +
+ ); + })} +
+ )} +
+ ); +}; + +export default LayoutBar; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 01d457c..2ae06c4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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); } diff --git a/frontend/tests/layoutBar.test.tsx b/frontend/tests/layoutBar.test.tsx new file mode 100644 index 0000000..e9a4e1a --- /dev/null +++ b/frontend/tests/layoutBar.test.tsx @@ -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(); + expect(screen.getByText(/Keine Layouts/)).toBeTruthy(); + }); + + it('listet Layouts mit Name + Minimap-SVG', () => { + const doc = fakeDoc([L1]); + render(); + 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(); + 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(); + fireEvent.click(screen.getByTitle('Layout hinzufügen')); + expect(doc.addLayout).toHaveBeenCalledTimes(1); + const added = (doc.addLayout as ReturnType).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(); + fireEvent.click(screen.getByTitle('Layout A4 Plan löschen')); + expect(doc.deleteLayout).toHaveBeenCalledWith('layout-1'); + }); + + it('ohne Bridge: Klick wirft nicht (no-op)', () => { + render(); + expect(() => fireEvent.click(screen.getByTestId('layout-entry-layout-1'))).not.toThrow(); + }); +});