AP1.13a: Read-only Gate-Graph aus Abhaengigkeiten.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Initiative-API fuer Roadmap-Dependencies, SVG-Layout mit Liste/Graph-Umschalter auf der Zielzustaende-Seite. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
83fdb9a2a2
commit
7f03504c2d
|
|
@ -115,6 +115,19 @@ def list_initiative_roadmap_items(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@initiative_router.get("/{initiative_id}/roadmap/dependencies")
|
||||||
|
def list_initiative_roadmap_dependencies(
|
||||||
|
initiative_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return roadmap_service.list_dependencies_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
||||||
def create_initiative_roadmap_item(
|
def create_initiative_roadmap_item(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,42 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_dependencies_for_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
|
||||||
|
d.dependency_type, d.created_at
|
||||||
|
FROM roadmap_item_dependencies d
|
||||||
|
INNER JOIN roadmap_items fi
|
||||||
|
ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id
|
||||||
|
INNER JOIN roadmaps r
|
||||||
|
ON r.id = fi.roadmap_id AND r.tenant_id = fi.tenant_id
|
||||||
|
WHERE d.tenant_id = %s AND r.initiative_id = %s
|
||||||
|
ORDER BY d.created_at ASC
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
dep = dict(row)
|
||||||
|
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
||||||
|
dep[key] = str(dep[key])
|
||||||
|
if dep.get("created_at"):
|
||||||
|
dep["created_at"] = dep["created_at"].isoformat()
|
||||||
|
items.append(dep)
|
||||||
|
return items
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def add_dependency(
|
def add_dependency(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
|
||||||
|
|
@ -120,3 +120,29 @@ def test_steering_snapshot_reads_roadmap_items(client):
|
||||||
assert snap.get("roadmap_items")
|
assert snap.get("roadmap_items")
|
||||||
assert snap.get("upcoming_milestones")
|
assert snap.get("upcoming_milestones")
|
||||||
assert len(snap["upcoming_roadmap_items"]) >= 1
|
assert len(snap["upcoming_roadmap_items"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_initiative_roadmap_dependencies_list(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json()
|
||||||
|
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||||
|
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
|
||||||
|
listed = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/dependencies",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
deps = listed.json()
|
||||||
|
assert len(deps) == 1
|
||||||
|
assert deps[0]["from_item_id"] == gate_a["id"]
|
||||||
|
assert deps[0]["to_item_id"] == gate_b["id"]
|
||||||
|
assert deps[0]["dependency_type"] == "requires"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ export function listInitiativeRoadmapItems(initiativeId) {
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInitiativeRoadmapDependencies(initiativeId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`)
|
||||||
|
}
|
||||||
|
|
||||||
export function createInitiativeRoadmapItem(initiativeId, body) {
|
export function createInitiativeRoadmapItem(initiativeId, body) {
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
186
frontend/src/components/GateMapView.jsx
Normal file
186
frontend/src/components/GateMapView.jsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { 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 { LoadingState } from './LoadingState.jsx'
|
||||||
|
import { ErrorState } from './ErrorState.jsx'
|
||||||
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
|
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)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GateMapView({ initiativeId, items }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [dependencies, setDependencies] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiativeId) {
|
||||||
|
setDependencies([])
|
||||||
|
setLoading(false)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
listInitiativeRoadmapDependencies(initiativeId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setDependencies(Array.isArray(data) ? data : [])
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initiativeId])
|
||||||
|
|
||||||
|
const layout = useMemo(
|
||||||
|
() => computeGateGraphLayout(items, dependencies),
|
||||||
|
[items, dependencies],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingState message="Gate-Graph wird geladen …" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorState message={error} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card gate-map-view">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Zielzustände (Graph)</h2>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Read-only Übersicht der Gate-Abhängigkeiten — Bearbeitung auf der Gate-Detailseite
|
||||||
|
(AP1.13b).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="gate-map-view__legend muted">
|
||||||
|
<span className="gate-map-view__legend-item">
|
||||||
|
<span className="gate-map-view__legend-line gate-map-view__legend-line--requires" />
|
||||||
|
Voraussetzung
|
||||||
|
</span>
|
||||||
|
<span className="gate-map-view__legend-item">
|
||||||
|
<span className="gate-map-view__legend-line gate-map-view__legend-line--blocks" />
|
||||||
|
Blockiert
|
||||||
|
</span>
|
||||||
|
<span className="gate-map-view__legend-item">
|
||||||
|
<span className="gate-map-view__legend-line gate-map-view__legend-line--related" />
|
||||||
|
Bezug
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="gate-map-view__canvas-wrap">
|
||||||
|
<svg
|
||||||
|
className="gate-map-view__canvas"
|
||||||
|
width={layout.width}
|
||||||
|
height={layout.height}
|
||||||
|
role="img"
|
||||||
|
aria-label="Gate-Abhängigkeitsgraph"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="gate-map-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-map-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}`}
|
||||||
|
markerEnd={
|
||||||
|
edge.dependency_type === 'related'
|
||||||
|
? undefined
|
||||||
|
: `url(#gate-map-arrow-${edge.dependency_type})`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{layout.nodes.map((node) => {
|
||||||
|
const item = node.item
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
className={`gate-map-view__node gate-map-view__node--${item.status || 'planned'}`}
|
||||||
|
transform={`translate(${node.x}, ${node.y})`}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${item.title} öffnen`}
|
||||||
|
onClick={() => navigate(gatePath(node.id))}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate(gatePath(node.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{dependencies.length === 0 && (
|
||||||
|
<p className="muted gate-map-view__hint">
|
||||||
|
Noch keine Kanten — Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den
|
||||||
|
Gate-Detailseiten gepflegt werden (AP1.13b).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
frontend/src/components/GatesPlanPanel.jsx
Normal file
68
frontend/src/components/GatesPlanPanel.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
|
||||||
|
import { GateMapView } from './GateMapView.jsx'
|
||||||
|
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
|
||||||
|
|
||||||
|
export function GatesPlanPanel({
|
||||||
|
initiativeId,
|
||||||
|
items,
|
||||||
|
canManage,
|
||||||
|
onCreate,
|
||||||
|
onReorder,
|
||||||
|
onDelete,
|
||||||
|
busy,
|
||||||
|
}) {
|
||||||
|
const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setViewMode(resolveGatesViewMode(initiativeId, items.length))
|
||||||
|
}, [initiativeId, items.length])
|
||||||
|
|
||||||
|
function handleViewChange(mode) {
|
||||||
|
setViewMode(mode)
|
||||||
|
storeGatesViewMode(initiativeId, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gates-plan-panel">
|
||||||
|
<div className="gates-plan-panel__toolbar" role="tablist" aria-label="Zielzustände Ansicht">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'list'}
|
||||||
|
className={
|
||||||
|
'gates-plan-panel__tab' + (viewMode === 'list' ? ' gates-plan-panel__tab--active' : '')
|
||||||
|
}
|
||||||
|
onClick={() => handleViewChange('list')}
|
||||||
|
>
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'graph'}
|
||||||
|
className={
|
||||||
|
'gates-plan-panel__tab' + (viewMode === 'graph' ? ' gates-plan-panel__tab--active' : '')
|
||||||
|
}
|
||||||
|
onClick={() => handleViewChange('graph')}
|
||||||
|
>
|
||||||
|
Graph
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'list' ? (
|
||||||
|
<RoadmapPlanSection
|
||||||
|
initiativeId={initiativeId}
|
||||||
|
items={items}
|
||||||
|
canManage={canManage}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onReorder={onReorder}
|
||||||
|
onDelete={onDelete}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GateMapView initiativeId={initiativeId} items={items} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
|
import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx'
|
||||||
|
|
||||||
export function InitiativePlanPage() {
|
export function InitiativePlanPage() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -20,7 +20,7 @@ export function InitiativePlanPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<RoadmapPlanSection
|
<GatesPlanPanel
|
||||||
initiativeId={initiativeId}
|
initiativeId={initiativeId}
|
||||||
items={roadmapItems}
|
items={roadmapItems}
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
|
|
|
||||||
194
frontend/src/plan/gateGraphLayout.js
Normal file
194
frontend/src/plan/gateGraphLayout.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/** Layout-Konstanten für read-only Gate-Graph (AP1.13a). */
|
||||||
|
export const GATE_NODE_WIDTH = 200
|
||||||
|
export const GATE_NODE_HEIGHT = 72
|
||||||
|
export const GATE_LAYER_GAP_Y = 56
|
||||||
|
export const GATE_NODE_GAP_X = 48
|
||||||
|
export const GATE_GRAPH_PADDING = 32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ id: string, title?: string, status?: string, sort_order?: number }} GateNode
|
||||||
|
* @typedef {{ from_item_id: string, to_item_id: string, dependency_type: string }} GateDependency
|
||||||
|
* @typedef {{ id: string, x: number, y: number, width: number, height: number, item: GateNode }} PositionedGateNode
|
||||||
|
* @typedef {{ id: string, fromId: string, toId: string, dependency_type: string, x1: number, y1: number, x2: number, y2: number }} GateGraphEdge
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanten für Schichtung: requires = Voraussetzung → abhängiges Gate;
|
||||||
|
* blocks = Blocker → geblocktes Gate.
|
||||||
|
*
|
||||||
|
* @param {GateDependency[]} dependencies
|
||||||
|
* @returns {{ from: string, to: string, dependency_type: string }[]}
|
||||||
|
*/
|
||||||
|
export function buildLayoutEdges(dependencies) {
|
||||||
|
const edges = []
|
||||||
|
for (const dep of dependencies) {
|
||||||
|
if (dep.dependency_type === 'requires') {
|
||||||
|
edges.push({
|
||||||
|
from: dep.to_item_id,
|
||||||
|
to: dep.from_item_id,
|
||||||
|
dependency_type: dep.dependency_type,
|
||||||
|
})
|
||||||
|
} else if (dep.dependency_type === 'blocks') {
|
||||||
|
edges.push({
|
||||||
|
from: dep.from_item_id,
|
||||||
|
to: dep.to_item_id,
|
||||||
|
dependency_type: dep.dependency_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {GateNode[]} items
|
||||||
|
* @param {GateDependency[]} dependencies
|
||||||
|
* @returns {{ nodes: PositionedGateNode[], edges: GateGraphEdge[], width: number, height: number }}
|
||||||
|
*/
|
||||||
|
export function computeGateGraphLayout(items, dependencies) {
|
||||||
|
if (!items.length) {
|
||||||
|
return { nodes: [], edges: [], width: 0, height: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedItems = [...items].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.sort_order ?? 0) - (b.sort_order ?? 0) ||
|
||||||
|
(a.title || '').localeCompare(b.title || '') ||
|
||||||
|
a.id.localeCompare(b.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
const layoutEdges = buildLayoutEdges(dependencies)
|
||||||
|
const layerById = new Map()
|
||||||
|
|
||||||
|
for (const item of sortedItems) {
|
||||||
|
layerById.set(item.id, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxIterations = sortedItems.length + 1
|
||||||
|
for (let i = 0; i < maxIterations; i += 1) {
|
||||||
|
let changed = false
|
||||||
|
for (const edge of layoutEdges) {
|
||||||
|
const fromLayer = layerById.get(edge.from) ?? 0
|
||||||
|
const toLayer = layerById.get(edge.to) ?? 0
|
||||||
|
const nextLayer = fromLayer + 1
|
||||||
|
if (nextLayer > toLayer) {
|
||||||
|
layerById.set(edge.to, nextLayer)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = new Map()
|
||||||
|
for (const item of sortedItems) {
|
||||||
|
const layer = layerById.get(item.id) ?? 0
|
||||||
|
if (!layers.has(layer)) layers.set(layer, [])
|
||||||
|
layers.get(layer).push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerIndices = [...layers.keys()].sort((a, b) => a - b)
|
||||||
|
const maxLayerWidth = Math.max(
|
||||||
|
...layerIndices.map((layer) => layers.get(layer).length),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
const nodes = []
|
||||||
|
for (const layer of layerIndices) {
|
||||||
|
const row = layers.get(layer)
|
||||||
|
const rowWidth =
|
||||||
|
row.length * GATE_NODE_WIDTH + Math.max(0, row.length - 1) * GATE_NODE_GAP_X
|
||||||
|
const canvasWidth =
|
||||||
|
maxLayerWidth * GATE_NODE_WIDTH +
|
||||||
|
Math.max(0, maxLayerWidth - 1) * GATE_NODE_GAP_X +
|
||||||
|
GATE_GRAPH_PADDING * 2
|
||||||
|
const startX = GATE_GRAPH_PADDING + Math.max(0, (canvasWidth - rowWidth) / 2 - GATE_GRAPH_PADDING)
|
||||||
|
|
||||||
|
row.forEach((item, index) => {
|
||||||
|
nodes.push({
|
||||||
|
id: item.id,
|
||||||
|
item,
|
||||||
|
width: GATE_NODE_WIDTH,
|
||||||
|
height: GATE_NODE_HEIGHT,
|
||||||
|
x: startX + index * (GATE_NODE_WIDTH + GATE_NODE_GAP_X),
|
||||||
|
y: GATE_GRAPH_PADDING + layer * (GATE_NODE_HEIGHT + GATE_LAYER_GAP_Y),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
||||||
|
const edges = dependencies
|
||||||
|
.map((dep) => {
|
||||||
|
const sourceNode =
|
||||||
|
dep.dependency_type === 'requires'
|
||||||
|
? nodeById.get(dep.to_item_id)
|
||||||
|
: nodeById.get(dep.from_item_id)
|
||||||
|
const targetNode =
|
||||||
|
dep.dependency_type === 'requires'
|
||||||
|
? nodeById.get(dep.from_item_id)
|
||||||
|
: nodeById.get(dep.to_item_id)
|
||||||
|
if (!sourceNode || !targetNode) return null
|
||||||
|
|
||||||
|
const center = (node) => ({
|
||||||
|
x: node.x + node.width / 2,
|
||||||
|
y: node.y + node.height / 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dep.dependency_type === 'related') {
|
||||||
|
const from = center(sourceNode)
|
||||||
|
const to = center(targetNode)
|
||||||
|
return {
|
||||||
|
id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
|
||||||
|
fromId: dep.from_item_id,
|
||||||
|
toId: dep.to_item_id,
|
||||||
|
dependency_type: dep.dependency_type,
|
||||||
|
x1: from.x,
|
||||||
|
y1: from.y,
|
||||||
|
x2: to.x,
|
||||||
|
y2: to.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
|
||||||
|
fromId: dep.from_item_id,
|
||||||
|
toId: dep.to_item_id,
|
||||||
|
dependency_type: dep.dependency_type,
|
||||||
|
x1: sourceNode.x + sourceNode.width / 2,
|
||||||
|
y1: sourceNode.y + sourceNode.height,
|
||||||
|
x2: targetNode.x + targetNode.width / 2,
|
||||||
|
y2: targetNode.y,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const width =
|
||||||
|
Math.max(...nodes.map((node) => node.x + node.width), GATE_NODE_WIDTH) + GATE_GRAPH_PADDING
|
||||||
|
const height =
|
||||||
|
Math.max(...nodes.map((node) => node.y + node.height), GATE_NODE_HEIGHT) + GATE_GRAPH_PADDING
|
||||||
|
|
||||||
|
return { nodes, edges, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} initiativeId
|
||||||
|
* @param {number} itemCount
|
||||||
|
* @returns {'list' | 'graph'}
|
||||||
|
*/
|
||||||
|
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') {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return itemCount <= 5 ? 'list' : 'graph'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} initiativeId
|
||||||
|
* @param {'list' | 'graph'} mode
|
||||||
|
*/
|
||||||
|
export function storeGatesViewMode(initiativeId, mode) {
|
||||||
|
if (typeof window !== 'undefined' && initiativeId) {
|
||||||
|
window.localStorage.setItem(`kairo.plan.gates.view:${initiativeId}`, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
frontend/src/plan/gateGraphLayout.test.js
Normal file
34
frontend/src/plan/gateGraphLayout.test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildLayoutEdges, computeGateGraphLayout } from './gateGraphLayout.js'
|
||||||
|
|
||||||
|
describe('gateGraphLayout', () => {
|
||||||
|
it('layers requires dependencies with prerequisite above dependent', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'a', title: 'A', sort_order: 0 },
|
||||||
|
{ id: 'b', title: 'B', sort_order: 1 },
|
||||||
|
]
|
||||||
|
const dependencies = [
|
||||||
|
{ id: 'd1', from_item_id: 'a', to_item_id: 'b', dependency_type: 'requires' },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(buildLayoutEdges(dependencies)).toEqual([
|
||||||
|
{ from: 'b', to: 'a', dependency_type: 'requires' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const layout = computeGateGraphLayout(items, dependencies)
|
||||||
|
const nodeA = layout.nodes.find((node) => node.id === 'a')
|
||||||
|
const nodeB = layout.nodes.find((node) => node.id === 'b')
|
||||||
|
expect(nodeB.y).toBeLessThan(nodeA.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places items without dependencies on one row by sort_order', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'b', title: 'B', sort_order: 10 },
|
||||||
|
{ id: 'a', title: 'A', sort_order: 0 },
|
||||||
|
]
|
||||||
|
const layout = computeGateGraphLayout(items, [])
|
||||||
|
expect(layout.nodes.map((node) => node.id)).toEqual(['a', 'b'])
|
||||||
|
expect(layout.nodes[0].y).toBe(layout.nodes[1].y)
|
||||||
|
expect(layout.nodes[0].x).toBeLessThan(layout.nodes[1].x)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1611,3 +1611,152 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gates-plan-panel__toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gates-plan-panel__tab {
|
||||||
|
border: 1px solid var(--jk-border, #dde3ea);
|
||||||
|
background: var(--jk-surface, #fff);
|
||||||
|
color: var(--jk-text-secondary, #445);
|
||||||
|
border-radius: var(--jk-radius-md, 8px);
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gates-plan-panel__tab--active {
|
||||||
|
background: var(--jk-surface-muted, #eef2f6);
|
||||||
|
color: var(--jk-text, #1a1a1a);
|
||||||
|
border-color: var(--jk-border-strong, #c5ced8);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__legend-line {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 0;
|
||||||
|
border-top-width: 2px;
|
||||||
|
border-top-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__legend-line--requires {
|
||||||
|
border-top-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__legend-line--blocks {
|
||||||
|
border-top-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__legend-line--related {
|
||||||
|
border-top-color: #64748b;
|
||||||
|
border-top-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__canvas-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--jk-border, #dde3ea);
|
||||||
|
border-radius: var(--jk-radius-md, 8px);
|
||||||
|
background: var(--jk-surface-subtle, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__canvas {
|
||||||
|
display: block;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__edge {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__edge--requires {
|
||||||
|
stroke: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__edge--blocks {
|
||||||
|
stroke: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__edge--related {
|
||||||
|
stroke: #64748b;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__arrow--requires {
|
||||||
|
fill: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__arrow--blocks {
|
||||||
|
fill: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node-box {
|
||||||
|
fill: #fff;
|
||||||
|
stroke: var(--jk-border-strong, #c5ced8);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node--active .gate-map-view__node-box,
|
||||||
|
.gate-map-view__node--planned .gate-map-view__node-box {
|
||||||
|
stroke: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node--at_risk .gate-map-view__node-box {
|
||||||
|
stroke: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node--reached .gate-map-view__node-box {
|
||||||
|
stroke: #16a34a;
|
||||||
|
fill: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node--discarded .gate-map-view__node-box,
|
||||||
|
.gate-map-view__node--moved .gate-map-view__node-box {
|
||||||
|
stroke: #94a3b8;
|
||||||
|
fill: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node-title {
|
||||||
|
fill: var(--jk-text, #1a1a1a);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node-meta {
|
||||||
|
fill: var(--jk-text-secondary, #445);
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__node:focus-visible .gate-map-view__node-box {
|
||||||
|
stroke: #2563eb;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-map-view__hint {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user