AP1.15a: Zielzustands-Designer mit Pan/Zoom und Kanten im Plan-Tab.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m19s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m19s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2843d8775a
commit
0c2ca4a69d
528
frontend/src/components/GateGraphDesigner.jsx
Normal file
528
frontend/src/components/GateGraphDesigner.jsx
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
addRoadmapItemDependency,
|
||||
deleteRoadmapDependency,
|
||||
listInitiativeRoadmapDependencies,
|
||||
} from '../api/roadmap.js'
|
||||
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import { computeGateGraphLayout } from '../plan/gateGraphLayout.js'
|
||||
import {
|
||||
applyManualGatePositions,
|
||||
clampDesignerZoom,
|
||||
clearManualGatePositions,
|
||||
DESIGNER_ZOOM_STEP,
|
||||
loadManualGatePositions,
|
||||
storeManualGatePositions,
|
||||
} from '../plan/gateGraphDesigner.js'
|
||||
import { LoadingState } from './LoadingState.jsx'
|
||||
import { ErrorState } from './ErrorState.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
||||
const EDGE_TYPE_LABELS = {
|
||||
requires: 'Voraussetzung',
|
||||
blocks: 'Blockiert',
|
||||
related: 'Bezug',
|
||||
}
|
||||
|
||||
const CONNECT_HINTS = {
|
||||
requires: 'Schritt 1: Voraussetzung wählen · Schritt 2: abhängiges Gate',
|
||||
blocks: 'Schritt 1: Blocker wählen · Schritt 2: geblocktes Gate',
|
||||
related: 'Schritt 1: erstes Gate · Schritt 2: zweites Gate',
|
||||
}
|
||||
|
||||
function edgePath(edge) {
|
||||
const midY = (edge.y1 + edge.y2) / 2
|
||||
return `M ${edge.x1} ${edge.y1} C ${edge.x1} ${midY}, ${edge.x2} ${midY}, ${edge.x2} ${edge.y2}`
|
||||
}
|
||||
|
||||
function truncateTitle(title, max = 28) {
|
||||
if (!title || title.length <= max) return title || 'Gate'
|
||||
return `${title.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMRect} rect
|
||||
* @param {{ x: number, y: number }} pan
|
||||
* @param {number} zoom
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
*/
|
||||
function clientToGraphPoint(rect, pan, zoom, clientX, clientY) {
|
||||
return {
|
||||
x: (clientX - rect.left - pan.x) / zoom,
|
||||
y: (clientY - rect.top - pan.y) / zoom,
|
||||
}
|
||||
}
|
||||
|
||||
export function GateGraphDesigner({
|
||||
initiativeId,
|
||||
items,
|
||||
canManage = false,
|
||||
onGraphChanged,
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const viewportRef = useRef(null)
|
||||
const [dependencies, setDependencies] = useState([])
|
||||
const [manualPositions, setManualPositions] = useState(() =>
|
||||
loadManualGatePositions(initiativeId),
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [tool, setTool] = useState('pan')
|
||||
const [edgeType, setEdgeType] = useState('requires')
|
||||
const [connectFromId, setConnectFromId] = useState(null)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
|
||||
const [pan, setPan] = useState({ x: 24, y: 24 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [draggingNodeId, setDraggingNodeId] = useState(null)
|
||||
const dragOffsetRef = useRef({ x: 0, y: 0 })
|
||||
const panStartRef = useRef(null)
|
||||
|
||||
const reloadDependencies = useCallback(async () => {
|
||||
if (!initiativeId) {
|
||||
setDependencies([])
|
||||
return
|
||||
}
|
||||
const data = await listInitiativeRoadmapDependencies(initiativeId)
|
||||
setDependencies(Array.isArray(data) ? data : [])
|
||||
}, [initiativeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setDependencies([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
reloadDependencies()
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err?.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId, reloadDependencies])
|
||||
|
||||
useEffect(() => {
|
||||
setManualPositions(loadManualGatePositions(initiativeId))
|
||||
setConnectFromId(null)
|
||||
setSelectedEdgeId(null)
|
||||
}, [initiativeId])
|
||||
|
||||
const baseLayout = useMemo(
|
||||
() => computeGateGraphLayout(items, dependencies),
|
||||
[items, dependencies],
|
||||
)
|
||||
|
||||
const layout = useMemo(
|
||||
() => applyManualGatePositions(baseLayout, manualPositions),
|
||||
[baseLayout, manualPositions],
|
||||
)
|
||||
|
||||
async function notifyGraphChanged() {
|
||||
await reloadDependencies()
|
||||
onGraphChanged?.()
|
||||
}
|
||||
|
||||
async function handleCreateEdge(fromId, toId) {
|
||||
if (!fromId || !toId || fromId === toId) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
let fromItemId = fromId
|
||||
let toItemId = toId
|
||||
if (edgeType === 'requires') {
|
||||
fromItemId = toId
|
||||
toItemId = fromId
|
||||
}
|
||||
await addRoadmapItemDependency(fromItemId, {
|
||||
to_item_id: toItemId,
|
||||
dependency_type: edgeType,
|
||||
})
|
||||
setConnectFromId(null)
|
||||
await notifyGraphChanged()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Kante konnte nicht angelegt werden.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteEdge(edgeId) {
|
||||
if (!edgeId || !canManage) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteRoadmapDependency(edgeId)
|
||||
setSelectedEdgeId(null)
|
||||
await notifyGraphChanged()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Kante konnte nicht entfernt werden.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeClick(nodeId, event) {
|
||||
event.stopPropagation()
|
||||
if (busy) return
|
||||
|
||||
if (tool === 'connect' && canManage) {
|
||||
if (!connectFromId) {
|
||||
setConnectFromId(nodeId)
|
||||
setSelectedEdgeId(null)
|
||||
return
|
||||
}
|
||||
if (connectFromId === nodeId) {
|
||||
setConnectFromId(null)
|
||||
return
|
||||
}
|
||||
handleCreateEdge(connectFromId, nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.detail === 2 && tool === 'pan') {
|
||||
navigate(gatePath(nodeId))
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodePointerDown(node, event) {
|
||||
if (!canManage || tool !== 'pan' || event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
|
||||
dragOffsetRef.current = { x: point.x - node.x, y: point.y - node.y }
|
||||
setDraggingNodeId(node.id)
|
||||
setSelectedEdgeId(null)
|
||||
}
|
||||
|
||||
function handleViewportPointerDown(event) {
|
||||
if (event.button !== 0) return
|
||||
const target = event.target
|
||||
if (target.closest?.('.gate-map-view__node')) return
|
||||
if (target.classList?.contains('gate-map-view__edge')) return
|
||||
|
||||
setSelectedEdgeId(null)
|
||||
if (tool === 'connect') {
|
||||
setConnectFromId(null)
|
||||
return
|
||||
}
|
||||
if (tool !== 'pan' || draggingNodeId) return
|
||||
|
||||
panStartRef.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
panX: pan.x,
|
||||
panY: pan.y,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerMove(event) {
|
||||
if (draggingNodeId && canManage) {
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
|
||||
const nextX = Math.max(0, point.x - dragOffsetRef.current.x)
|
||||
const nextY = Math.max(0, point.y - dragOffsetRef.current.y)
|
||||
setManualPositions((prev) => {
|
||||
const next = { ...prev, [draggingNodeId]: { x: nextX, y: nextY } }
|
||||
storeManualGatePositions(initiativeId, next)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (panStartRef.current) {
|
||||
const deltaX = event.clientX - panStartRef.current.startX
|
||||
const deltaY = event.clientY - panStartRef.current.startY
|
||||
setPan({
|
||||
x: panStartRef.current.panX + deltaX,
|
||||
y: panStartRef.current.panY + deltaY,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
setDraggingNodeId(null)
|
||||
panStartRef.current = null
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [canManage, draggingNodeId, initiativeId, pan, zoom])
|
||||
|
||||
function handleWheel(event) {
|
||||
event.preventDefault()
|
||||
const direction = event.deltaY > 0 ? -1 : 1
|
||||
setZoom((current) => clampDesignerZoom(current + direction * DESIGNER_ZOOM_STEP))
|
||||
}
|
||||
|
||||
function handleResetLayout() {
|
||||
clearManualGatePositions(initiativeId)
|
||||
setManualPositions({})
|
||||
setPan({ x: 24, y: 24 })
|
||||
setZoom(1)
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Zielzustands-Designer wird geladen …" />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card gate-graph-designer">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Zielzustände (Designer)</h2>
|
||||
<p className="section-lead muted">
|
||||
Zielzustands-Graph modellieren — Pan/Zoom, Knoten verschieben, Kanten verbinden. Verify
|
||||
und Kriterien bleiben auf der Gate-Detailseite.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
<div className="gate-graph-designer__toolbar">
|
||||
<div className="gate-graph-designer__tool-group" role="group" aria-label="Werkzeug">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'btn btn-secondary btn-sm' +
|
||||
(tool === 'pan' ? ' gate-graph-designer__tool--active' : '')
|
||||
}
|
||||
aria-pressed={tool === 'pan'}
|
||||
onClick={() => {
|
||||
setTool('pan')
|
||||
setConnectFromId(null)
|
||||
}}
|
||||
>
|
||||
Verschieben
|
||||
</button>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'btn btn-secondary btn-sm' +
|
||||
(tool === 'connect' ? ' gate-graph-designer__tool--active' : '')
|
||||
}
|
||||
aria-pressed={tool === 'connect'}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setTool('connect')
|
||||
setConnectFromId(null)
|
||||
setSelectedEdgeId(null)
|
||||
}}
|
||||
>
|
||||
Verbinden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && tool === 'connect' && (
|
||||
<label className="gate-graph-designer__edge-type">
|
||||
<span className="muted">Kantentyp</span>
|
||||
<select
|
||||
value={edgeType}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setEdgeType(event.target.value)
|
||||
setConnectFromId(null)
|
||||
}}
|
||||
>
|
||||
{Object.entries(EDGE_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="gate-graph-designer__zoom-group" role="group" aria-label="Zoom">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
aria-label="Verkleinern"
|
||||
onClick={() => setZoom((current) => clampDesignerZoom(current - DESIGNER_ZOOM_STEP))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="gate-graph-designer__zoom-label muted">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
aria-label="Vergrößern"
|
||||
onClick={() => setZoom((current) => clampDesignerZoom(current + DESIGNER_ZOOM_STEP))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManage && selectedEdgeId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => handleDeleteEdge(selectedEdgeId)}
|
||||
>
|
||||
Kante entfernen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={handleResetLayout}
|
||||
>
|
||||
Layout zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tool === 'connect' && canManage && (
|
||||
<p className="gate-graph-designer__hint muted">
|
||||
{CONNECT_HINTS[edgeType] || 'Zwei Gates nacheinander anklicken.'}
|
||||
{connectFromId ? ' — erstes Gate gewählt, zweites wählen.' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canManage && (
|
||||
<p className="gate-graph-designer__hint muted">
|
||||
Nur Ansicht — zum Modellieren fehlt die Berechtigung „Plan verwalten“.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="gate-graph-designer__viewport"
|
||||
onPointerDown={handleViewportPointerDown}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<svg
|
||||
className="gate-graph-designer__canvas"
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
role="img"
|
||||
aria-label="Zielzustands-Designer"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="gate-designer-arrow-requires"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="6"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path
|
||||
d="M0,0 L8,4 L0,8 Z"
|
||||
className="gate-map-view__arrow gate-map-view__arrow--requires"
|
||||
/>
|
||||
</marker>
|
||||
<marker
|
||||
id="gate-designer-arrow-blocks"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="6"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path
|
||||
d="M0,0 L8,4 L0,8 Z"
|
||||
className="gate-map-view__arrow gate-map-view__arrow--blocks"
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{layout.edges.map((edge) => (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={edgePath(edge)}
|
||||
className={
|
||||
'gate-map-view__edge gate-map-view__edge--' +
|
||||
edge.dependency_type +
|
||||
(selectedEdgeId === edge.id ? ' gate-graph-designer__edge--selected' : '')
|
||||
}
|
||||
markerEnd={
|
||||
edge.dependency_type === 'related'
|
||||
? undefined
|
||||
: `url(#gate-designer-arrow-${edge.dependency_type})`
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (canManage) setSelectedEdgeId(edge.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{layout.nodes.map((node) => {
|
||||
const item = node.item
|
||||
const isConnectSource = connectFromId === node.id
|
||||
const nodeClass =
|
||||
'gate-map-view__node gate-map-view__node--' +
|
||||
(item.status || 'planned') +
|
||||
(isConnectSource ? ' gate-graph-designer__node--connect-source' : '') +
|
||||
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className={nodeClass}
|
||||
transform={`translate(${node.x}, ${node.y})`}
|
||||
onPointerDown={(event) => handleNodePointerDown(node, event)}
|
||||
onClick={(event) => handleNodeClick(node.id, event)}
|
||||
>
|
||||
<rect
|
||||
className="gate-map-view__node-box"
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx="8"
|
||||
ry="8"
|
||||
/>
|
||||
<text className="gate-map-view__node-title" x="12" y="28">
|
||||
{truncateTitle(item.title)}
|
||||
</text>
|
||||
<text className="gate-map-view__node-meta" x="12" y="50">
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<p className="muted gate-graph-designer__footer-hint">
|
||||
Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -86,8 +86,8 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })
|
|||
<div>
|
||||
<h2>Zielzustände (Graph)</h2>
|
||||
<p className="section-lead muted">
|
||||
Read-only Übersicht der Gate-Abhängigkeiten — Kanten auf der Gate-Detailseite pflegen,
|
||||
Verify und Kriterien dort ebenfalls.
|
||||
Read-only Übersicht der Gate-Abhängigkeiten — im Designer modellieren oder auf der
|
||||
Gate-Detailseite Kanten pflegen; Verify und Kriterien dort.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -197,8 +197,8 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })
|
|||
|
||||
{dependencies.length === 0 && (
|
||||
<p className="muted gate-map-view__hint">
|
||||
Noch keine Kanten — auf der Gate-Detailseite unter „Abhängigkeiten“ anlegen; ohne Kanten
|
||||
gelten Gates nach Reihenfolge.
|
||||
Noch keine Kanten — im Designer verbinden oder auf der Gate-Detailseite unter
|
||||
„Abhängigkeiten“ anlegen; ohne Kanten gelten Gates nach Reihenfolge.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
|
||||
import { GateMapView } from './GateMapView.jsx'
|
||||
import { GateGraphDesigner } from './GateGraphDesigner.jsx'
|
||||
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
|
||||
import { listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||
|
||||
|
|
@ -43,6 +44,13 @@ export function GatesPlanPanel({
|
|||
storeGatesViewMode(initiativeId, mode)
|
||||
}
|
||||
|
||||
function refreshGraphState() {
|
||||
if (!initiativeId) return
|
||||
listInitiativeRoadmapGraphState(initiativeId)
|
||||
.then((data) => setGraphState(data || null))
|
||||
.catch(() => setGraphState(null))
|
||||
}
|
||||
|
||||
const fulfillmentPct =
|
||||
graphState?.initiative_fulfillment_ratio != null
|
||||
? Math.round(graphState.initiative_fulfillment_ratio * 100)
|
||||
|
|
@ -87,6 +95,18 @@ export function GatesPlanPanel({
|
|||
>
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'designer'}
|
||||
className={
|
||||
'gates-plan-panel__tab' +
|
||||
(viewMode === 'designer' ? ' gates-plan-panel__tab--active' : '')
|
||||
}
|
||||
onClick={() => handleViewChange('designer')}
|
||||
>
|
||||
Designer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' ? (
|
||||
|
|
@ -100,8 +120,15 @@ export function GatesPlanPanel({
|
|||
busy={busy}
|
||||
graphState={graphState}
|
||||
/>
|
||||
) : (
|
||||
) : viewMode === 'graph' ? (
|
||||
<GateMapView initiativeId={initiativeId} items={items} graphState={graphState} />
|
||||
) : (
|
||||
<GateGraphDesigner
|
||||
initiativeId={initiativeId}
|
||||
items={items}
|
||||
canManage={canManage}
|
||||
onGraphChanged={refreshGraphState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
100
frontend/src/plan/gateGraphDesigner.js
Normal file
100
frontend/src/plan/gateGraphDesigner.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/** Designer-Hilfen für Zielzustands-Graph (AP1.15a) — kein Workflow. */
|
||||
|
||||
export const DESIGNER_MIN_ZOOM = 0.5
|
||||
export const DESIGNER_MAX_ZOOM = 2
|
||||
export const DESIGNER_ZOOM_STEP = 0.1
|
||||
|
||||
/**
|
||||
* @param {number} zoom
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampDesignerZoom(zoom) {
|
||||
return Math.min(DESIGNER_MAX_ZOOM, Math.max(DESIGNER_MIN_ZOOM, zoom))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} initiativeId
|
||||
* @returns {Record<string, { x: number, y: number }>}
|
||||
*/
|
||||
export function loadManualGatePositions(initiativeId) {
|
||||
if (typeof window === 'undefined' || !initiativeId) return {}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`kairo.plan.gateLayout:${initiativeId}`)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} initiativeId
|
||||
* @param {Record<string, { x: number, y: number }>} positions
|
||||
*/
|
||||
export function storeManualGatePositions(initiativeId, positions) {
|
||||
if (typeof window === 'undefined' || !initiativeId) return
|
||||
window.localStorage.setItem(
|
||||
`kairo.plan.gateLayout:${initiativeId}`,
|
||||
JSON.stringify(positions),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} initiativeId
|
||||
*/
|
||||
export function clearManualGatePositions(initiativeId) {
|
||||
if (typeof window === 'undefined' || !initiativeId) return
|
||||
window.localStorage.removeItem(`kairo.plan.gateLayout:${initiativeId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ nodes: Array<{ id: string, x: number, y: number, width: number, height: number, item: object }> }} layout
|
||||
* @param {Record<string, { x: number, y: number }>} manualPositions
|
||||
*/
|
||||
export function applyManualGatePositions(layout, manualPositions) {
|
||||
if (!layout?.nodes?.length || !manualPositions) return layout
|
||||
|
||||
const nodes = layout.nodes.map((node) => {
|
||||
const manual = manualPositions[node.id]
|
||||
if (!manual) return node
|
||||
return { ...node, x: manual.x, y: manual.y }
|
||||
})
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
||||
const edges = (layout.edges || []).map((edge) => {
|
||||
const sourceNode =
|
||||
edge.dependency_type === 'requires'
|
||||
? nodeById.get(edge.toId || edge.from_item_id)
|
||||
: nodeById.get(edge.fromId || edge.from_item_id)
|
||||
const targetNode =
|
||||
edge.dependency_type === 'requires'
|
||||
? nodeById.get(edge.fromId || edge.to_item_id)
|
||||
: nodeById.get(edge.toId || edge.to_item_id)
|
||||
if (!sourceNode || !targetNode) return edge
|
||||
|
||||
const center = (node) => ({
|
||||
x: node.x + node.width / 2,
|
||||
y: node.y + node.height / 2,
|
||||
})
|
||||
|
||||
if (edge.dependency_type === 'related') {
|
||||
const from = center(sourceNode)
|
||||
const to = center(targetNode)
|
||||
return { ...edge, x1: from.x, y1: from.y, x2: to.x, y2: to.y }
|
||||
}
|
||||
|
||||
return {
|
||||
...edge,
|
||||
x1: sourceNode.x + sourceNode.width / 2,
|
||||
y1: sourceNode.y + sourceNode.height,
|
||||
x2: targetNode.x + targetNode.width / 2,
|
||||
y2: targetNode.y,
|
||||
}
|
||||
})
|
||||
|
||||
const width = Math.max(...nodes.map((node) => node.x + node.width), 0) + 32
|
||||
const height = Math.max(...nodes.map((node) => node.y + node.height), 0) + 32
|
||||
|
||||
return { ...layout, nodes, edges, width, height }
|
||||
}
|
||||
28
frontend/src/plan/gateGraphDesigner.test.js
Normal file
28
frontend/src/plan/gateGraphDesigner.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyManualGatePositions,
|
||||
clampDesignerZoom,
|
||||
} from './gateGraphDesigner.js'
|
||||
|
||||
describe('gateGraphDesigner', () => {
|
||||
it('clamps zoom range', () => {
|
||||
expect(clampDesignerZoom(0.1)).toBe(0.5)
|
||||
expect(clampDesignerZoom(3)).toBe(2)
|
||||
expect(clampDesignerZoom(1)).toBe(1)
|
||||
})
|
||||
|
||||
it('applies manual node positions', () => {
|
||||
const layout = {
|
||||
nodes: [
|
||||
{ id: 'a', x: 10, y: 20, width: 100, height: 72, item: {} },
|
||||
{ id: 'b', x: 200, y: 20, width: 100, height: 72, item: {} },
|
||||
],
|
||||
edges: [],
|
||||
width: 400,
|
||||
height: 200,
|
||||
}
|
||||
const merged = applyManualGatePositions(layout, { a: { x: 50, y: 80 } })
|
||||
expect(merged.nodes.find((n) => n.id === 'a').x).toBe(50)
|
||||
expect(merged.nodes.find((n) => n.id === 'b').x).toBe(200)
|
||||
})
|
||||
})
|
||||
|
|
@ -171,12 +171,12 @@ export function computeGateGraphLayout(items, dependencies) {
|
|||
/**
|
||||
* @param {string} initiativeId
|
||||
* @param {number} itemCount
|
||||
* @returns {'list' | 'graph'}
|
||||
* @returns {'list' | 'graph' | 'designer'}
|
||||
*/
|
||||
export function resolveGatesViewMode(initiativeId, itemCount) {
|
||||
if (typeof window !== 'undefined' && initiativeId) {
|
||||
const stored = window.localStorage.getItem(`kairo.plan.gates.view:${initiativeId}`)
|
||||
if (stored === 'list' || stored === 'graph') {
|
||||
if (stored === 'list' || stored === 'graph' || stored === 'designer') {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ export function resolveGatesViewMode(initiativeId, itemCount) {
|
|||
|
||||
/**
|
||||
* @param {string} initiativeId
|
||||
* @param {'list' | 'graph'} mode
|
||||
* @param {'list' | 'graph' | 'designer'} mode
|
||||
*/
|
||||
export function storeGatesViewMode(initiativeId, mode) {
|
||||
if (typeof window !== 'undefined' && initiativeId) {
|
||||
|
|
|
|||
|
|
@ -1874,6 +1874,85 @@
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__tool-group,
|
||||
.gate-graph-designer__zoom-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__tool--active {
|
||||
border-color: var(--jk-border-strong, #c5ced8);
|
||||
background: var(--jk-surface-muted, #eef2f6);
|
||||
}
|
||||
|
||||
.gate-graph-designer__edge-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__zoom-label {
|
||||
min-width: 3rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__hint,
|
||||
.gate-graph-designer__footer-hint {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__viewport {
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--jk-border, #dde3ea);
|
||||
border-radius: 8px;
|
||||
background: var(--jk-surface-muted, #f6f8fa);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.gate-graph-designer__viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.gate-graph-designer__canvas {
|
||||
display: block;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.gate-graph-designer__edge--selected {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.gate-graph-designer__node--connect-source .gate-map-view__node-box {
|
||||
stroke: var(--jk-accent, #2563eb);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node--dragging {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.gate-graph-designer .gate-map-view__edge {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gate-dependencies-section {
|
||||
margin: 1.25rem 0;
|
||||
padding-top: 1rem;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user