Kairo-Jinkendo/frontend/src/components/GateGraphDesigner.jsx

529 lines
16 KiB
React
Raw Normal View History

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>
)
}