Initial commit
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { useMemo, useCallback, useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, applyNodeChanges } from '@xyflow/react'
|
||||
import type { Node, Edge, NodeChange } from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import type { OrgRole, OrgEmployee } from '../types/visual'
|
||||
import { StructureCanvasNode, type StructureCanvasNodeData } from './StructureCanvasNode'
|
||||
import { computeDagreLayout } from './dagreLayout'
|
||||
|
||||
const nodeTypes = { roleNode: StructureCanvasNode }
|
||||
|
||||
export interface StructureCanvasHandle {
|
||||
/** Re-run dagre and animate nodes to tidy positions. */
|
||||
autoLayout: () => void
|
||||
}
|
||||
|
||||
interface StructureCanvasProps {
|
||||
roles: OrgRole[]
|
||||
employees: OrgEmployee[]
|
||||
/**
|
||||
* role_id -> recruited names for the currently selected session. When
|
||||
* provided it takes precedence over the global `employees` for the node
|
||||
* subtitle, so the canvas reflects the selected session's hires. Null/absent
|
||||
* -> fall back to the global org employees.
|
||||
*/
|
||||
sessionRecruitmentByRole?: Record<string, string[]> | null
|
||||
selectedRoleId: string | null
|
||||
onSelectRole: (roleId: string | null) => void
|
||||
onReparent: (roleId: string, newParentId: string) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrained canvas for org structure.
|
||||
* Positions are managed internally. See D1 + D3 in the plan doc:
|
||||
* - dagre runs on mount, on role add/delete, and on explicit autoLayout() calls.
|
||||
* - Role-field updates do NOT reflow the graph.
|
||||
* - Reparenting reflows (a new parent->child edge would leave the graph stale).
|
||||
*/
|
||||
export const StructureCanvas = forwardRef<StructureCanvasHandle, StructureCanvasProps>(function StructureCanvas(props, ref) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<StructureCanvasInner {...props} forwardedRef={ref} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
})
|
||||
|
||||
function StructureCanvasInner({ roles, employees, sessionRecruitmentByRole, selectedRoleId, onSelectRole, onReparent, readOnly, forwardedRef }: StructureCanvasProps & { forwardedRef: React.ForwardedRef<StructureCanvasHandle> }) {
|
||||
const employeesByRole = useMemo(() => {
|
||||
const m = new Map<string, OrgEmployee[]>()
|
||||
for (const e of employees) {
|
||||
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
|
||||
for (const roleId of roleIds) {
|
||||
if (!roleId) continue
|
||||
const arr = m.get(roleId) ?? []
|
||||
arr.push(e)
|
||||
m.set(roleId, arr)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}, [employees])
|
||||
|
||||
// Names of the actually recruited people per role.
|
||||
// - When the selected session carries a recruitment map, it is authoritative
|
||||
// (a role absent from it is unstaffed *for that session*).
|
||||
// - Otherwise fall back to the global org employees, excluding placeholder/
|
||||
// default employees (which carry the role name itself, not a real hire).
|
||||
const recruitedNamesByRole = useCallback(
|
||||
(roleId: string): string[] => {
|
||||
if (sessionRecruitmentByRole) return sessionRecruitmentByRole[roleId] ?? []
|
||||
return (employeesByRole.get(roleId) ?? [])
|
||||
.filter(e => !e.is_default_employee)
|
||||
.map(e => e.name)
|
||||
.filter(Boolean)
|
||||
},
|
||||
[employeesByRole, sessionRecruitmentByRole],
|
||||
)
|
||||
|
||||
// Layout invalidation key. Incrementing -> dagre re-runs.
|
||||
const [layoutVersion, setLayoutVersion] = useState(0)
|
||||
|
||||
// Re-layout automatically when the set of role IDs changes (add/delete).
|
||||
const roleIdsKey = useMemo(() => roles.map(r => r.role_id).sort().join('|'), [roles])
|
||||
const prevRoleIdsKeyRef = useRef(roleIdsKey)
|
||||
useEffect(() => {
|
||||
if (prevRoleIdsKeyRef.current !== roleIdsKey) {
|
||||
prevRoleIdsKeyRef.current = roleIdsKey
|
||||
setLayoutVersion(v => v + 1)
|
||||
}
|
||||
}, [roleIdsKey])
|
||||
|
||||
// Expose imperative autoLayout() to parent so the "Auto-layout" button can trigger it.
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
autoLayout: () => setLayoutVersion(v => v + 1),
|
||||
}), [])
|
||||
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
|
||||
// Free-form drag positions: a node the user has dragged sticks where it was
|
||||
// dropped (purely visual — never changes the org structure). Cleared whenever
|
||||
// dagre re-runs (autoLayout / role add-delete / reparent) so an explicit
|
||||
// "Auto-layout" tidies everything back into the hierarchy.
|
||||
const [manualPositions, setManualPositions] = useState<Record<string, { x: number; y: number }>>({})
|
||||
useEffect(() => { setManualPositions({}) }, [layoutVersion])
|
||||
|
||||
// Compute layout once per layoutVersion bump (NOT on every roles update).
|
||||
const laidOut = useMemo(() => {
|
||||
const ownerNode: Node<StructureCanvasNodeData> = {
|
||||
id: 'owner',
|
||||
type: 'roleNode',
|
||||
position: { x: 0, y: 0 },
|
||||
draggable: false,
|
||||
data: {
|
||||
roleId: 'owner', name: 'You (Owner)', responsibility: '',
|
||||
icon: null, employeeCount: 0, employeeNames: [],
|
||||
isOwner: true, isSelected: false, isDropTarget: false,
|
||||
},
|
||||
}
|
||||
const roleNodes: Node<StructureCanvasNodeData>[] = roles.map(r => ({
|
||||
id: r.role_id,
|
||||
type: 'roleNode',
|
||||
position: { x: 0, y: 0 },
|
||||
// Always draggable: in editable mode a drop onto another node reparents;
|
||||
// otherwise the drag is a free visual reposition (no structural change).
|
||||
draggable: true,
|
||||
data: {
|
||||
roleId: r.role_id, name: r.name, responsibility: r.responsibility,
|
||||
icon: r.icon ?? null, employeeCount: recruitedNamesByRole(r.role_id).length,
|
||||
employeeNames: recruitedNamesByRole(r.role_id),
|
||||
isOwner: false, isSelected: false, isDropTarget: false,
|
||||
},
|
||||
}))
|
||||
const all = [ownerNode, ...roleNodes]
|
||||
const edges: Edge[] = roles.map(r => ({
|
||||
id: `e-${r.reports_to}-${r.role_id}`,
|
||||
source: r.reports_to,
|
||||
target: r.role_id,
|
||||
type: 'smoothstep',
|
||||
}))
|
||||
const positioned = computeDagreLayout(all, edges)
|
||||
return { nodes: positioned, edges }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- layoutVersion is the explicit reflow trigger
|
||||
}, [layoutVersion, readOnly])
|
||||
|
||||
// Live nodes: start from laid-out positions; overlay current role data
|
||||
// (name/icon/etc.) without re-running dagre.
|
||||
const liveNodes = useMemo(() => {
|
||||
return laidOut.nodes.map(n => {
|
||||
if (n.id === 'owner') return n
|
||||
const role = roles.find(r => r.role_id === n.id)
|
||||
if (!role) return n // node for a deleted role -- invariant: layoutVersion will bump and this clears
|
||||
const manual = manualPositions[n.id]
|
||||
return {
|
||||
...n,
|
||||
position: manual ?? n.position,
|
||||
draggable: true,
|
||||
data: {
|
||||
...n.data,
|
||||
name: role.name,
|
||||
responsibility: role.responsibility,
|
||||
icon: role.icon ?? null,
|
||||
employeeCount: recruitedNamesByRole(role.role_id).length,
|
||||
employeeNames: recruitedNamesByRole(role.role_id),
|
||||
isSelected: role.role_id === selectedRoleId,
|
||||
isDropTarget: role.role_id === dropTargetId,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [laidOut.nodes, roles, employeesByRole, recruitedNamesByRole, selectedRoleId, dropTargetId, readOnly, manualPositions])
|
||||
|
||||
const [stateNodes, setStateNodes] = useState(liveNodes)
|
||||
useEffect(() => { setStateNodes(liveNodes) }, [liveNodes])
|
||||
|
||||
const handleNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
setStateNodes(curr => applyNodeChanges(changes, curr))
|
||||
}, [])
|
||||
|
||||
const handleNodeDrag = useCallback((_evt: any, node: Node) => {
|
||||
// Reparent drop-target highlighting only applies in editable mode. In
|
||||
// read-only mode the drag is a pure visual reposition -- no target.
|
||||
if (readOnly) return
|
||||
// Bounding-box hit test for drop target
|
||||
const W = 220, H = 80
|
||||
const pt = { x: node.position.x + W / 2, y: node.position.y + H / 2 }
|
||||
const hit = stateNodes.find(n => {
|
||||
if (n.id === node.id) return false
|
||||
return pt.x >= n.position.x && pt.x <= n.position.x + W &&
|
||||
pt.y >= n.position.y && pt.y <= n.position.y + H
|
||||
})
|
||||
setDropTargetId(hit?.id ?? null)
|
||||
}, [stateNodes, readOnly])
|
||||
|
||||
const handleNodeDragStop = useCallback((_evt: any, node: Node) => {
|
||||
const target = dropTargetId
|
||||
setDropTargetId(null)
|
||||
// Remember the dropped position so the node stays put (visual only).
|
||||
const keepPosition = () =>
|
||||
setManualPositions(prev => ({ ...prev, [node.id]: { x: node.position.x, y: node.position.y } }))
|
||||
// No valid reparent (read-only, no/own target, or a cycle) -> free reposition.
|
||||
if (readOnly || !target || target === node.id || isDescendant(roles, node.id, target)) {
|
||||
keepPosition()
|
||||
return
|
||||
}
|
||||
onReparent(node.id, target)
|
||||
// A reparent changes the edge structure -- reflow (also clears manual positions).
|
||||
setLayoutVersion(v => v + 1)
|
||||
}, [dropTargetId, roles, readOnly, onReparent])
|
||||
|
||||
const handleNodeClick = useCallback((_evt: any, node: Node) => {
|
||||
onSelectRole(node.id === 'owner' ? null : node.id)
|
||||
}, [onSelectRole])
|
||||
|
||||
// @xyflow/react v12's `.react-flow` CSS class does NOT set height/width.
|
||||
// Wrap in a sized div so the canvas has a definite box to render into —
|
||||
// this is the library's documented integration pattern for v12.
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<ReactFlow
|
||||
nodes={stateNodes}
|
||||
edges={laidOut.edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={handleNodesChange}
|
||||
onNodeDrag={handleNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onNodeClick={handleNodeClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.32, minZoom: 0.45, maxZoom: 1.2 }}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<Background gap={28} size={1} color="rgba(240, 237, 232, 0.10)" />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap pannable nodeStrokeWidth={0} maskColor="rgba(12, 17, 27, 0.6)" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Note: no ReactFlow `proOptions.hideAttribution` used -- @xyflow/react v12 is MIT
|
||||
// with the attribution fully removed at the library level, so no workaround needed.
|
||||
|
||||
/** Helper: is `candidateDescendantId` a descendant of `rootId`? */
|
||||
function isDescendant(roles: OrgRole[], rootId: string, candidateDescendantId: string): boolean {
|
||||
const children = roles.filter(r => r.reports_to === rootId).map(r => r.role_id)
|
||||
for (const c of children) {
|
||||
if (c === candidateDescendantId) return true
|
||||
if (isDescendant(roles, c, candidateDescendantId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user