task(G2): layout bar UI + viewport minimap widget, zoomPan setView, canvas layout bridge

This commit is contained in:
Agent Zero
2026-08-29 02:43:51 +02:00
parent d4e1b781a6
commit 7feed9e9e3
6 changed files with 454 additions and 0 deletions
+15
View File
@@ -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());
+190
View File
@@ -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;