diff --git a/src/components/display-and-visualization/process-model/ProcessModelActivityNode.tsx b/src/components/display-and-visualization/process-model/ProcessModelActivityNode.tsx new file mode 100644 index 0000000..2dd2bf5 --- /dev/null +++ b/src/components/display-and-visualization/process-model/ProcessModelActivityNode.tsx @@ -0,0 +1,77 @@ +import type { MouseEventHandler, PointerEventHandler, ReactNode } from 'react' +import clsx from 'clsx' +import { PropsUtil } from '@/src/utils/propsUtil' + +export type ProcessModelActivityNodeKind = 'activity' | 'terminal' + +export type ProcessModelActivityNodeProps = { + nodeId: string, + label: string, + count: string, + customIcon: ReactNode, + kind?: ProcessModelActivityNodeKind, + bordered?: boolean, + active?: boolean, + visited?: boolean, + className?: string, + onClick?: MouseEventHandler, + onPointerEnter?: PointerEventHandler, + onPointerLeave?: PointerEventHandler, +} + +export const ProcessModelActivityNode = ({ + nodeId, + label, + count, + customIcon, + kind = 'activity', + bordered = true, + active = false, + visited = false, + className, + onClick, + onPointerEnter, + onPointerLeave, +}: ProcessModelActivityNodeProps) => { + const interactive = !!onClick + const rootName = kind === 'terminal' ? 'process-model-terminal-node' : 'process-model-activity-node' + return ( +
{ + onClick?.(event) + }} + onPointerEnter={(event) => { + onPointerEnter?.(event) + }} + onPointerLeave={(event) => { + onPointerLeave?.(event) + }} + onKeyDown={(event) => { + if (!interactive) { + return + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.currentTarget.click() + } + }} + > +
{customIcon}
+
+
{label}
+
{count}
+
+
+ ) +} diff --git a/src/components/display-and-visualization/process-model/ProcessModelCanvas.tsx b/src/components/display-and-visualization/process-model/ProcessModelCanvas.tsx new file mode 100644 index 0000000..e760618 --- /dev/null +++ b/src/components/display-and-visualization/process-model/ProcessModelCanvas.tsx @@ -0,0 +1,233 @@ +import { useId, useMemo, type ReactNode } from 'react' +import clsx from 'clsx' +import { Check, Plus } from 'lucide-react' +import { ProcessModelActivityNode } from './ProcessModelActivityNode' +import { ProcessModelTerminalNode } from './ProcessModelTerminalNode' +import { + computeLayout, + getEdgePoints, + getProcessModelEdgePathDomId, + maxEdgeWeight, + weightToStyle +} from './layoutProcessModel' +import type { + ProcessModelGraph, + ProcessModelGraphActivityNode, + ProcessModelGraphNode +} from './types' + +export type ProcessModelCanvasProps = { + graph: ProcessModelGraph, + className?: string, + showNodeBorder?: boolean, + activeNodeId?: string, + visitedNodeIds?: ReadonlySet, + renderActivityIcon?: (node: ProcessModelGraphActivityNode) => ReactNode, + edgePathIdPrefix?: string, + edgeReplayHighlight?: { from: string, to: string } | null, + replayParticle?: { cx: number, cy: number, opacity: number } | null, +} + +function defaultRenderActivityIcon(node: ProcessModelGraphActivityNode): ReactNode { + if (node.activityIcon === 'check') { + return + } + return +} + +function isVisited( + nodeId: string, + activeNodeId: string | undefined, + visited: ReadonlySet | undefined +): boolean { + if (!visited?.size) { + return false + } + if (nodeId === activeNodeId) { + return false + } + return visited.has(nodeId) +} + +export const ProcessModelCanvas = ({ + graph, + className, + showNodeBorder = true, + activeNodeId, + visitedNodeIds, + renderActivityIcon = defaultRenderActivityIcon, + edgePathIdPrefix, + edgeReplayHighlight = null, + replayParticle = null, +}: ProcessModelCanvasProps) => { + const uid = useId().replace(/:/g, '') + const { positions, canvasW, canvasH } = useMemo(() => computeLayout(graph), [graph]) + const maxW = useMemo(() => maxEdgeWeight(graph.edges), [graph.edges]) + + const edgeElements = useMemo(() => { + return graph.edges.map((edge) => { + const pts = getEdgePoints(positions, edge.from, edge.to, graph.edges) + if (!pts) { + return null + } + const { opacity, sw, markerTier } = weightToStyle(edge.weight, maxW) + const lp = pts.labelPt + const pillW = edge.label.length * 6.8 + 12 + const pillH = 17 + const labelStrong = opacity > 0.7 + const isReplayEdge = + edgeReplayHighlight != null + && edge.from === edgeReplayHighlight.from + && edge.to === edgeReplayHighlight.to + const pathId = getProcessModelEdgePathDomId(edgePathIdPrefix, edge.from, edge.to) + const strokeOpacity = isReplayEdge ? 1 : opacity + const strokeWidth = isReplayEdge ? 3 : sw + return ( + + + {edge.label ? ( + <> + + + {edge.label} + + + ) : null} + + ) + }) + }, [graph, positions, maxW, uid, edgePathIdPrefix, edgeReplayHighlight]) + + const nodeElements = useMemo(() => { + const visited = visitedNodeIds ?? new Set() + return graph.nodes.map((node: ProcessModelGraphNode) => { + const p = positions[node.id] + if (!p) { + return null + } + const active = node.id === activeNodeId + const wasVisited = isVisited(node.id, activeNodeId, visited) + let inner: ReactNode + if (node.type === 'start' || node.type === 'end') { + inner = ( + + ) + } else { + const activityNode = node as ProcessModelGraphActivityNode + inner = ( + + ) + } + return ( + +
{inner}
+
+ ) + }) + }, [graph.nodes, positions, activeNodeId, visitedNodeIds, renderActivityIcon, showNodeBorder]) + + return ( +
+ + + {(['strong', 'medium', 'faint'] as const).map((tier) => { + const op = tier === 'strong' ? 1 : tier === 'medium' ? 0.65 : 0.32 + return ( + + + + ) + })} + + + + + {edgeElements} + {nodeElements} + {replayParticle ? ( + + ) : null} + +
+ ) +} diff --git a/src/components/display-and-visualization/process-model/ProcessModelTerminalNode.tsx b/src/components/display-and-visualization/process-model/ProcessModelTerminalNode.tsx new file mode 100644 index 0000000..95b5a61 --- /dev/null +++ b/src/components/display-and-visualization/process-model/ProcessModelTerminalNode.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from 'react' +import { ProcessModelActivityNode } from './ProcessModelActivityNode' +import { terminalCountDisplayLine } from './layoutProcessModel' +import type { ProcessModelTerminalKind } from './types' + +export type ProcessModelTerminalNodeProps = { + nodeId: string, + variant: ProcessModelTerminalKind, + label: string, + count: string, + bordered?: boolean, + active?: boolean, + visited?: boolean, + className?: string, +} + +function TerminalHexIcon({ variant }: { variant: ProcessModelTerminalKind }): ReactNode { + const isStart = variant === 'start' + return ( + + + + {isStart ? ( + + ) : ( + + )} + + ) +} + +export const ProcessModelTerminalNode = ({ + nodeId, + variant, + label, + count, + bordered = true, + active = false, + visited = false, + className, +}: ProcessModelTerminalNodeProps) => { + return ( + } + bordered={bordered} + active={active} + visited={visited} + className={className} + /> + ) +} diff --git a/src/components/display-and-visualization/process-model/ProcessModelTraceReplay.tsx b/src/components/display-and-visualization/process-model/ProcessModelTraceReplay.tsx new file mode 100644 index 0000000..bd82fb7 --- /dev/null +++ b/src/components/display-and-visualization/process-model/ProcessModelTraceReplay.tsx @@ -0,0 +1,318 @@ +import { + useCallback, + useEffect, + useId, + useRef, + useState +} from 'react' +import clsx from 'clsx' +import { ProcessModelCanvas } from './ProcessModelCanvas' +import { getProcessModelEdgePathDomId } from './layoutProcessModel' +import type { ProcessModelGraphWithTraces, ProcessModelTrace } from './types' + +export type ProcessModelTraceReplayProps = { + graph: ProcessModelGraphWithTraces, + className?: string, +} + +type LogLine = { + id: string, + time: string, + text: string, +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + const id = setTimeout(() => { + resolve() + }, ms) + signal.addEventListener('abort', () => { + clearTimeout(id) + resolve() + }, { once: true }) + }) +} + +function runParticleAlongPath( + path: SVGPathElement, + durationMs: number, + signal: AbortSignal, + onFrame: (cx: number, cy: number, opacity: number) => void +): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + const len = path.getTotalLength() + if (!Number.isFinite(len) || len <= 0) { + onFrame(0, 0, 0) + resolve() + return + } + const start = performance.now() + const tick = (now: number) => { + if (signal.aborted) { + onFrame(0, 0, 0) + resolve() + return + } + const u = Math.min(1, (now - start) / durationMs) + const pt = path.getPointAtLength(u * len) + const opacity = u < 0.06 ? u / 0.06 : u > 0.88 ? (1 - u) / 0.12 : 1 + onFrame(pt.x, pt.y, opacity) + if (u < 1) { + requestAnimationFrame(tick) + } else { + onFrame(0, 0, 0) + resolve() + } + } + requestAnimationFrame(tick) + }) +} + +export const ProcessModelTraceReplay = ({ graph, className }: ProcessModelTraceReplayProps) => { + const pathPrefix = useId().replace(/:/g, '') + const hostRef = useRef(null) + const traceIndexRef = useRef(0) + const playbackRef = useRef(null) + const playbackLoopLockRef = useRef(false) + + const [speedMult, setSpeedMult] = useState(2) + const [isPlaying, setIsPlaying] = useState(false) + const [activeNodeId, setActiveNodeId] = useState() + const [visitedNodeIds, setVisitedNodeIds] = useState>(new Set()) + const [edgeReplayHighlight, setEdgeReplayHighlight] = useState<{ from: string, to: string } | null>(null) + const [replayParticle, setReplayParticle] = useState<{ cx: number, cy: number, opacity: number } | null>(null) + const [logLines, setLogLines] = useState([]) + const [logPlaceholder, setLogPlaceholder] = useState(true) + + const getPath = useCallback( + (from: string, to: string): SVGPathElement | null => { + const root = hostRef.current + if (!root) { + return null + } + const svg = root.querySelector('svg.process-model-svg') + if (!svg) { + return null + } + const id = getProcessModelEdgePathDomId(pathPrefix, from, to) + return svg.querySelector(`#${CSS.escape(id)}`) + }, + [pathPrefix] + ) + + const addLog = useCallback((text: string) => { + setLogPlaceholder(false) + const time = new Date().toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + setLogLines((prev) => [ + ...prev, + { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, time, text }, + ]) + }, []) + + const getLabel = useCallback( + (id: string) => { + const n = graph.nodes.find((x) => x.id === id) + return n ? `${n.label} (${n.count})` : id + }, + [graph.nodes] + ) + + const stopPlayback = useCallback(() => { + playbackRef.current?.abort() + playbackRef.current = null + setIsPlaying(false) + setReplayParticle(null) + }, []) + + const resetReplay = useCallback(() => { + stopPlayback() + traceIndexRef.current = 0 + setActiveNodeId(undefined) + setVisitedNodeIds(new Set()) + setEdgeReplayHighlight(null) + setLogLines([]) + setLogPlaceholder(true) + }, [stopPlayback]) + + const awaitDoubleRaf = useCallback(() => { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()) + }) + }) + }, []) + + const runTraceOnce = useCallback( + async (trace: ProcessModelTrace, signal: AbortSignal) => { + await awaitDoubleRaf() + if (signal.aborted) { + return + } + const BASE = 650 / speedMult + const ns = trace.nodes + addLog(`▶ ${trace.name}`) + setVisitedNodeIds(new Set()) + setEdgeReplayHighlight(null) + setActiveNodeId(ns[0]) + addLog(`↳ ${getLabel(ns[0])}`) + + for (let i = 0; i < ns.length - 1; i++) { + if (signal.aborted) { + return + } + const from = ns[i] + const to = ns[i + 1] + const dur = (BASE + Math.random() * 180) / speedMult + const pathEl = getPath(from, to) + const particleMs = dur / speedMult + if (pathEl) { + await runParticleAlongPath(pathEl, particleMs, signal, (cx, cy, opacity) => { + if (opacity <= 0) { + setReplayParticle(null) + } else { + setReplayParticle({ cx, cy, opacity }) + } + }) + } else { + setReplayParticle(null) + await sleep(particleMs, signal) + } + if (signal.aborted) { + return + } + setVisitedNodeIds((prev) => new Set([...prev, from])) + setActiveNodeId(to) + setEdgeReplayHighlight({ from, to }) + addLog(`↳ ${getLabel(to)}`) + if (i === ns.length - 2) { + await sleep(500 / speedMult, signal) + if (signal.aborted) { + return + } + setActiveNodeId(undefined) + setEdgeReplayHighlight(null) + } + } + }, + [addLog, awaitDoubleRaf, getLabel, getPath, speedMult] + ) + + const startPlayback = useCallback(async () => { + if (!graph.traces.length || playbackLoopLockRef.current) { + return + } + playbackLoopLockRef.current = true + stopPlayback() + const ac = new AbortController() + playbackRef.current = ac + setIsPlaying(true) + setLogPlaceholder(false) + try { + while (!ac.signal.aborted) { + const idx = traceIndexRef.current % graph.traces.length + const trace = graph.traces[idx] + traceIndexRef.current += 1 + await runTraceOnce(trace, ac.signal) + if (ac.signal.aborted) { + break + } + const gap = (600 + Math.random() * 300) / speedMult + await sleep(gap, ac.signal) + } + } finally { + if (playbackRef.current === ac) { + playbackRef.current = null + } + playbackLoopLockRef.current = false + setIsPlaying(false) + setReplayParticle(null) + } + }, [graph.traces, runTraceOnce, speedMult, stopPlayback]) + + useEffect(() => { + resetReplay() + }, [graph, resetReplay]) + + useEffect(() => () => { + playbackRef.current?.abort() + }, []) + + return ( +
+
+ + + +
+
+ +
+
+
Trace log
+
+ {logPlaceholder ? ( +
+ Press Play to start trace replay… +
+ ) : ( + logLines.map((line) => ( +
+ + {line.time} + {line.text} +
+ )) + )} +
+
+
+ ) +} diff --git a/src/components/display-and-visualization/process-model/layoutProcessModel.ts b/src/components/display-and-visualization/process-model/layoutProcessModel.ts new file mode 100644 index 0000000..1954173 --- /dev/null +++ b/src/components/display-and-visualization/process-model/layoutProcessModel.ts @@ -0,0 +1,360 @@ +import type { + ProcessModelEdge, + ProcessModelEdgePointResult, + ProcessModelEdgeStrokeStyle, + ProcessModelGraph, + ProcessModelGraphNode, + ProcessModelLayoutResult, + ProcessModelNodePosition +} from './types' +import type { ProcessModelActivityNodeKind } from './ProcessModelActivityNode' + +const NODE_H = 52 +const COL_GAP = 72 +const ROW_GAP = 72 + +export const ACTIVITY_NODE_MIN_WIDTH = 120 + +const ACTIVITY_PAD_X = 24 +const ACTIVITY_ICON_W = 28 +const ACTIVITY_GAP = 8 +const ACTIVITY_LABEL_PX_PER_CHAR = 6.9 +const ACTIVITY_COUNT_PX_PER_CHAR = 6.15 +const TERMINAL_HEX_W = 34 +const TERMINAL_GAP = 12 +const TERMINAL_LABEL_PX_PER_CHAR = 7.5 +const TERMINAL_COUNT_PX_PER_CHAR = 6.2 +const ACTIVITY_WIDTH_SAFETY_PX = 14 + +function stringVisualUnits(s: string): number { + return [...s].length +} + +export function terminalCountDisplayLine(rawCount: string): string { + return `${rawCount} traces` +} + +export function estimateProcessModelActivityChromeWidth( + kind: ProcessModelActivityNodeKind, + label: string, + countLine: string +): number { + const labelPx = kind === 'terminal' ? TERMINAL_LABEL_PX_PER_CHAR : ACTIVITY_LABEL_PX_PER_CHAR + const countPx = kind === 'terminal' ? TERMINAL_COUNT_PX_PER_CHAR : ACTIVITY_COUNT_PX_PER_CHAR + const labelW = stringVisualUnits(label) * labelPx + const countW = stringVisualUnits(countLine) * countPx + const textW = Math.max(labelW, countW) + const iconW = kind === 'terminal' ? TERMINAL_HEX_W : ACTIVITY_ICON_W + const gap = kind === 'terminal' ? TERMINAL_GAP : ACTIVITY_GAP + const chrome = ACTIVITY_PAD_X + iconW + gap + const w = chrome + textW + ACTIVITY_WIDTH_SAFETY_PX + return Math.max(ACTIVITY_NODE_MIN_WIDTH, Math.ceil(w)) +} + +export function estimateProcessModelActivityNodeWidth(label: string, count: string): number { + return estimateProcessModelActivityChromeWidth('activity', label, count) +} + +const EDGE_LABEL_ALONG_FR = 0.22 +const EDGE_LABEL_BEZIER_T = 0.2 +const EDGE_STRAIGHT_PX = 14 + +type CubicPt = { x: number, y: number } + +function cubicBezierPoint( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + t: number +): { x: number, y: number } { + const u = 1 - t + const u2 = u * u + const u3 = u2 * u + const t2 = t * t + const t3 = t2 * t + return { + x: u3 * x0 + 3 * u2 * t * x1 + 3 * u * t2 * x2 + t3 * x3, + y: u3 * y0 + 3 * u2 * t * y1 + 3 * u * t2 * y2 + t3 * y3, + } +} + +function lerpPt(a: CubicPt, b: CubicPt, t: number): CubicPt { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t } +} + +function splitCubicAt( + p0: CubicPt, + p1: CubicPt, + p2: CubicPt, + p3: CubicPt, + t: number +): { left: [CubicPt, CubicPt, CubicPt, CubicPt], right: [CubicPt, CubicPt, CubicPt, CubicPt] } { + const q0 = lerpPt(p0, p1, t) + const q1 = lerpPt(p1, p2, t) + const q2 = lerpPt(p2, p3, t) + const r0 = lerpPt(q0, q1, t) + const r1 = lerpPt(q1, q2, t) + const s = lerpPt(r0, r1, t) + return { + left: [p0, q0, r0, s], + right: [s, r1, q2, p3], + } +} + +function linePathDWithStraightCaps(sx: number, sy: number, ex: number, ey: number, run: number): string { + const dx = ex - sx + const dy = ey - sy + const len = Math.hypot(dx, dy) + if (len < 1e-9) { + return `M${sx},${sy}` + } + const ux = dx / len + const uy = dy / len + const inset = Math.min(run, Math.max(0, (len - 2) / 2)) + if (inset < 0.5) { + return `M${sx},${sy} L${ex},${ey}` + } + return `M${sx + ux * inset},${sy + uy * inset} L${ex - ux * inset},${ey - uy * inset}` +} + +function cubicPathDWithEndStraightCap( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + runPx: number +): string { + const hull = + Math.hypot(x1 - x0, y1 - y0) + Math.hypot(x2 - x1, y2 - y1) + Math.hypot(x3 - x2, y3 - y2) + if (hull < runPx * 2 + 6) { + return `M${x0},${y0} C${x1},${y1} ${x2},${y2} ${x3},${y3}` + } + let lo = 0 + let hi = 1 + for (let i = 0; i < 28; i++) { + const mid = (lo + hi) / 2 + const b = cubicBezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, mid) + const d = Math.hypot(x3 - b.x, y3 - b.y) + if (d > runPx) { + lo = mid + } else { + hi = mid + } + } + const t = Math.min(0.9999, Math.max(0.0001, hi)) + const p0: CubicPt = { x: x0, y: y0 } + const p1: CubicPt = { x: x1, y: y1 } + const p2: CubicPt = { x: x2, y: y2 } + const p3: CubicPt = { x: x3, y: y3 } + const [l0, l1, l2, l3] = splitCubicAt(p0, p1, p2, p3, t).left + const gap = Math.hypot(x3 - l3.x, y3 - l3.y) + if (gap < 0.75) { + return `M${l0.x},${l0.y} C${l1.x},${l1.y} ${l2.x},${l2.y} ${x3},${y3}` + } + return `M${l0.x},${l0.y} C${l1.x},${l1.y} ${l2.x},${l2.y} ${l3.x},${l3.y} L${x3},${y3}` +} + +function nodeWidth(n: ProcessModelGraphNode): number { + if (n.type === 'start' || n.type === 'end') { + return estimateProcessModelActivityChromeWidth( + 'terminal', + n.label, + terminalCountDisplayLine(n.count) + ) + } + return estimateProcessModelActivityNodeWidth(n.label, n.count) +} + +export function computeLayout(graph: ProcessModelGraph): ProcessModelLayoutResult { + const nodes = graph.nodes + const layers: Record = {} + nodes.forEach((n) => { + const l = n.layer ?? 0 + if (!layers[l]) { + layers[l] = [] + } + layers[l].push(n) + }) + const layerKeys = Object.keys(layers).map(Number).sort((a, b) => a - b) + layerKeys.forEach((l) => { + layers[l].sort((a, b) => (a.col ?? 0) - (b.col ?? 0)) + }) + + const layerTotalWidths = layerKeys.map((l) => { + const ns = layers[l] + return ns.reduce((s, n) => s + nodeWidth(n), 0) + Math.max(0, ns.length - 1) * COL_GAP + }) + const canvasW = Math.max(...layerTotalWidths) + 120 + + const positions: Record = {} + let curY = 40 + layerKeys.forEach((l, li) => { + const ns = layers[l] + const totalW = layerTotalWidths[li] + let curX = (canvasW - totalW) / 2 + ns.forEach((n) => { + const w = nodeWidth(n) + positions[n.id] = { x: curX, y: curY, w, h: NODE_H, layer: l } + curX += w + COL_GAP + }) + curY += NODE_H + ROW_GAP + }) + const canvasH = curY - ROW_GAP + 40 + + return { positions, canvasW, canvasH } +} + +export function getEdgePoints( + pos: Record, + fromId: string, + toId: string, + allEdges: ProcessModelEdge[] +): ProcessModelEdgePointResult | null { + const f = pos[fromId] + const t = pos[toId] + if (!f || !t) { + return null + } + + const fc = { x: f.x + f.w / 2, y: f.y + f.h / 2 } + const tc = { x: t.x + t.w / 2, y: t.y + t.h / 2 } + + const dx = tc.x - fc.x + + const sameLayer = f.layer === t.layer + const isBackEdge = t.layer < f.layer + const layerSpan = Math.abs(t.layer - f.layer) + const isSkipEdge = !isBackEdge && layerSpan > 1 + const sameColumn = Math.abs(dx) < 8 + const hasPair = allEdges.some((e) => e.from === toId && e.to === fromId) + + let pathD: string + let labelPt: { x: number, y: number } + + function leftArcRoute(): ProcessModelEdgePointResult { + const minL = Math.min(f.layer, t.layer) + const maxL = Math.max(f.layer, t.layer) + let leftmostX = Math.min(f.x, t.x) + Object.entries(pos).forEach(([nid, p]) => { + if (p.layer >= minL && p.layer <= maxL && nid !== fromId && nid !== toId) { + leftmostX = Math.min(leftmostX, p.x) + } + }) + const margin = 56 + layerSpan * 8 + const leftX = leftmostX - margin + const sx = f.x + const sy = fc.y + const ex = t.x + const ey = tc.y + const labelPt = cubicBezierPoint(sx, sy, leftX, sy, leftX, ey, ex, ey, EDGE_LABEL_BEZIER_T) + const run = EDGE_STRAIGHT_PX + const hSign = Math.sign(leftX - sx) || -1 + const hInset = Math.min(run, Math.max(0, Math.abs(leftX - sx) * 0.35)) + const sx2 = sx + hSign * hInset + const inner = cubicPathDWithEndStraightCap(sx2, sy, leftX, sy, leftX, ey, ex, ey, run) + const cIdx = inner.indexOf(' C') + const pathD = + hInset > 0.25 && cIdx >= 0 + ? `M${sx},${sy} L${sx2},${sy}${inner.slice(cIdx)}` + : inner + return { + pathD, + labelPt, + } + } + + if (hasPair) { + const idxForward = allEdges.findIndex((e) => e.from === fromId && e.to === toId) + const idxReverse = allEdges.findIndex((e) => e.from === toId && e.to === fromId) + const isForward = idxForward < idxReverse + const offsetY = isForward ? -8 : 8 + const sx = dx > 0 ? f.x + f.w : f.x + const ex = dx > 0 ? t.x : t.x + t.w + const sy = fc.y + offsetY + const ey = tc.y + offsetY + pathD = linePathDWithStraightCaps(sx, sy, ex, ey, EDGE_STRAIGHT_PX) + labelPt = { x: sx + (ex - sx) * EDGE_LABEL_ALONG_FR, y: sy - 11 } + } else if (isBackEdge || isSkipEdge) { + const arc = leftArcRoute() + pathD = arc.pathD + labelPt = arc.labelPt + } else if (sameLayer) { + const sx = dx > 0 ? f.x + f.w : f.x + const ex = dx > 0 ? t.x : t.x + t.w + const sy = fc.y + const ey = tc.y + pathD = linePathDWithStraightCaps(sx, sy, ex, ey, EDGE_STRAIGHT_PX) + labelPt = { x: sx + (ex - sx) * EDGE_LABEL_ALONG_FR, y: sy - 11 } + } else if (sameColumn) { + const sx = fc.x + const sy = f.y + f.h + const ex = tc.x + const ey = t.y + pathD = linePathDWithStraightCaps(sx, sy, ex, ey, EDGE_STRAIGHT_PX) + const alongY = sy + (ey - sy) * EDGE_LABEL_ALONG_FR + labelPt = { x: sx + 16, y: alongY } + } else { + const sx = fc.x + const sy = f.y + f.h + const ex = tc.x + const ey = t.y + const span = Math.abs(ey - sy) + const cp1y = sy + span * 0.45 + const cp2y = ey - span * 0.45 + const run = EDGE_STRAIGHT_PX + const vSign = Math.sign(cp1y - sy) || 1 + const vInset = Math.min(run, Math.max(0, Math.abs(cp1y - sy) * 0.35)) + const sy2 = sy + vSign * vInset + const inner = cubicPathDWithEndStraightCap(sx, sy2, sx, cp1y, ex, cp2y, ex, ey, run) + const cIdx = inner.indexOf(' C') + pathD = + vInset > 0.25 && cIdx >= 0 + ? `M${sx},${sy} L${sx},${sy2}${inner.slice(cIdx)}` + : inner + labelPt = cubicBezierPoint(sx, sy, sx, cp1y, ex, cp2y, ex, ey, EDGE_LABEL_BEZIER_T) + const offX = dx > 0 ? 10 : -10 + labelPt = { x: labelPt.x + offX, y: labelPt.y - 5 } + } + + return { pathD, labelPt } +} + +export function weightToStyle(weight: number, maxWeight: number): ProcessModelEdgeStrokeStyle { + const ratio = maxWeight > 0 ? weight / maxWeight : 0 + if (ratio > 0.6) { + return { opacity: 1.0, sw: 2.5, markerTier: 'strong' } + } + if (ratio > 0.2) { + return { opacity: 0.6, sw: 1.8, markerTier: 'medium' } + } + return { opacity: 0.28, sw: 1.4, markerTier: 'faint' } +} + +export function maxEdgeWeight(edges: ProcessModelEdge[]): number { + if (!edges.length) { + return 1 + } + return Math.max(...edges.map((e) => e.weight)) +} + +export { NODE_H } + +export function getProcessModelEdgePathDomId( + pathIdPrefix: string | undefined, + from: string, + to: string +): string { + if (pathIdPrefix != null && pathIdPrefix !== '') { + return `ep-${pathIdPrefix}-${from}-${to}` + } + return `ep-${from}-${to}` +} diff --git a/src/components/display-and-visualization/process-model/processModelLibrary.ts b/src/components/display-and-visualization/process-model/processModelLibrary.ts new file mode 100644 index 0000000..0b275b7 --- /dev/null +++ b/src/components/display-and-visualization/process-model/processModelLibrary.ts @@ -0,0 +1,258 @@ +import type { ProcessModelLibraryEntry } from './types' + +export const processModelLibrary: ProcessModelLibraryEntry[] = [ + { + id: 'patient-flow', + name: 'Patient Flow', + description: 'Hospital patient admission to discharge · 1,280 total traces', + graph: { + nodes: [ + { id: 'start', label: 'Patient created', count: '1.280', type: 'start', layer: 0, col: 0 }, + { id: 'task-172', label: 'Task added', count: '172', type: 'activity', layer: 1, col: 0 }, + { id: 'assign-1108', label: 'Assign bed', count: '1.108', type: 'activity', layer: 1, col: 1 }, + { id: 'assign-208', label: 'Assign bed', count: '208', type: 'activity', layer: 2, col: 0 }, + { id: 'task-900', label: 'Task added', count: '900', type: 'activity', layer: 2, col: 1 }, + { id: 'task-done', label: 'Task done', count: '900', type: 'activity', layer: 2, col: 2, activityIcon: 'check' }, + { id: 'unassigned', label: 'Unassigned', count: '1.108', type: 'activity', layer: 3, col: 0 }, + { id: 'end', label: 'Patient discharged', count: '1.280', type: 'end', layer: 4, col: 0 }, + ], + edges: [ + { from: 'start', to: 'assign-1108', label: '1.108', weight: 1108 }, + { from: 'start', to: 'task-172', label: '172', weight: 172 }, + { from: 'assign-1108', to: 'task-900', label: '900', weight: 900 }, + { from: 'assign-1108', to: 'assign-208', label: '208', weight: 208 }, + { from: 'task-172', to: 'assign-208', label: '172', weight: 172 }, + { from: 'task-900', to: 'unassigned', label: '900', weight: 900 }, + { from: 'task-900', to: 'task-done', label: '528', weight: 528 }, + { from: 'task-done', to: 'task-900', label: '528', weight: 528 }, + { from: 'assign-208', to: 'unassigned', label: '208', weight: 208 }, + { from: 'assign-208', to: 'end', label: '172', weight: 172 }, + { from: 'unassigned', to: 'end', label: '1.108', weight: 1108 }, + ], + traces: [ + { name: 'Main path', nodes: ['start', 'assign-1108', 'task-900', 'unassigned', 'end'] }, + { name: 'Task loop ×1', nodes: ['start', 'assign-1108', 'task-900', 'task-done', 'task-900', 'unassigned', 'end'] }, + { name: 'Task loop ×2', nodes: ['start', 'assign-1108', 'task-900', 'task-done', 'task-900', 'task-done', 'task-900', 'unassigned', 'end'] }, + { name: 'Alt: Task added → Assign 208', nodes: ['start', 'task-172', 'assign-208', 'unassigned', 'end'] }, + { name: 'Alt: Assign bed → Assign 208', nodes: ['start', 'assign-1108', 'assign-208', 'unassigned', 'end'] }, + { name: 'Short: directly discharged', nodes: ['start', 'task-172', 'assign-208', 'end'] }, + { name: 'Short: Assign bed → discharged', nodes: ['start', 'assign-1108', 'assign-208', 'end'] }, + ], + }, + }, + { + id: 'order-fulfillment', + name: 'Order Fulfillment', + description: 'E-commerce order from placement to delivery · 3,440 traces', + graph: { + nodes: [ + { id: 'start', label: 'Order placed', count: '3.440', type: 'start', layer: 0, col: 0 }, + { id: 'payment', label: 'Payment check', count: '3.440', type: 'activity', layer: 1, col: 0 }, + { id: 'fraud', label: 'Fraud review', count: '412', type: 'activity', layer: 2, col: 0 }, + { id: 'pick', label: 'Pick & pack', count: '3.028', type: 'activity', layer: 2, col: 1 }, + { id: 'reject', label: 'Order rejected', count: '89', type: 'activity', layer: 3, col: 0 }, + { id: 'ship', label: 'Shipped', count: '3.351', type: 'activity', layer: 3, col: 1 }, + { id: 'return', label: 'Return initiated', count: '187', type: 'activity', layer: 4, col: 0 }, + { id: 'deliver', label: 'Delivered', count: '3.164', type: 'activity', layer: 4, col: 1 }, + { id: 'end', label: 'Order complete', count: '3.440', type: 'end', layer: 5, col: 0 }, + ], + edges: [ + { from: 'start', to: 'payment', label: '3.440', weight: 3440 }, + { from: 'payment', to: 'fraud', label: '412', weight: 412 }, + { from: 'payment', to: 'pick', label: '3.028', weight: 3028 }, + { from: 'fraud', to: 'reject', label: '89', weight: 89 }, + { from: 'fraud', to: 'pick', label: '323', weight: 323 }, + { from: 'pick', to: 'ship', label: '3.351', weight: 3351 }, + { from: 'ship', to: 'deliver', label: '3.164', weight: 3164 }, + { from: 'ship', to: 'return', label: '187', weight: 187 }, + { from: 'return', to: 'pick', label: '142', weight: 142 }, + { from: 'deliver', to: 'end', label: '3.164', weight: 3164 }, + { from: 'reject', to: 'end', label: '89', weight: 89 }, + { from: 'return', to: 'end', label: '45', weight: 45 }, + ], + traces: [ + { name: 'Happy path', nodes: ['start', 'payment', 'pick', 'ship', 'deliver', 'end'] }, + { name: 'Fraud review → cleared', nodes: ['start', 'payment', 'fraud', 'pick', 'ship', 'deliver', 'end'] }, + { name: 'Fraud review → rejected', nodes: ['start', 'payment', 'fraud', 'reject', 'end'] }, + { name: 'Return & reship', nodes: ['start', 'payment', 'pick', 'ship', 'return', 'pick', 'ship', 'deliver', 'end'] }, + { name: 'Return only', nodes: ['start', 'payment', 'pick', 'ship', 'return', 'end'] }, + { name: 'Fraud + return', nodes: ['start', 'payment', 'fraud', 'pick', 'ship', 'return', 'pick', 'ship', 'deliver', 'end'] }, + ], + }, + }, + { + id: 'software-deploy', + name: 'Software Deployment', + description: 'CI/CD pipeline from commit to production · 892 runs', + graph: { + nodes: [ + { id: 'start', label: 'Commit pushed', count: '892', type: 'start', layer: 0, col: 0 }, + { id: 'lint', label: 'Lint & format', count: '892', type: 'activity', layer: 1, col: 0 }, + { id: 'test', label: 'Unit tests', count: '851', type: 'activity', layer: 2, col: 0 }, + { id: 'build', label: 'Build artifact', count: '819', type: 'activity', layer: 3, col: 0 }, + { id: 'staging', label: 'Deploy staging', count: '819', type: 'activity', layer: 4, col: 0 }, + { id: 'e2e', label: 'E2E tests', count: '794', type: 'activity', layer: 5, col: 0 }, + { id: 'approve', label: 'Manual approval', count: '761', type: 'activity', layer: 5, col: 1 }, + { id: 'prod', label: 'Deploy prod', count: '761', type: 'activity', layer: 6, col: 0 }, + { id: 'rollback', label: 'Rollback', count: '58', type: 'activity', layer: 7, col: 0 }, + { id: 'end', label: 'Release complete', count: '892', type: 'end', layer: 8, col: 0 }, + ], + edges: [ + { from: 'start', to: 'lint', label: '892', weight: 892 }, + { from: 'lint', to: 'test', label: '851', weight: 851 }, + { from: 'lint', to: 'end', label: '41', weight: 41 }, + { from: 'test', to: 'build', label: '819', weight: 819 }, + { from: 'test', to: 'end', label: '32', weight: 32 }, + { from: 'build', to: 'staging', label: '819', weight: 819 }, + { from: 'staging', to: 'e2e', label: '794', weight: 794 }, + { from: 'staging', to: 'approve', label: '25', weight: 25 }, + { from: 'e2e', to: 'approve', label: '761', weight: 761 }, + { from: 'e2e', to: 'end', label: '33', weight: 33 }, + { from: 'approve', to: 'prod', label: '761', weight: 761 }, + { from: 'prod', to: 'rollback', label: '58', weight: 58 }, + { from: 'prod', to: 'end', label: '703', weight: 703 }, + { from: 'rollback', to: 'staging', label: '31', weight: 31 }, + { from: 'rollback', to: 'end', label: '27', weight: 27 }, + ], + traces: [ + { name: 'Green path', nodes: ['start', 'lint', 'test', 'build', 'staging', 'e2e', 'approve', 'prod', 'end'] }, + { name: 'Lint failure', nodes: ['start', 'lint', 'end'] }, + { name: 'Test failure', nodes: ['start', 'lint', 'test', 'end'] }, + { name: 'E2E failure', nodes: ['start', 'lint', 'test', 'build', 'staging', 'e2e', 'end'] }, + { name: 'Rollback & retry', nodes: ['start', 'lint', 'test', 'build', 'staging', 'e2e', 'approve', 'prod', 'rollback', 'staging', 'e2e', 'approve', 'prod', 'end'] }, + { name: 'Skip E2E (fast track)', nodes: ['start', 'lint', 'test', 'build', 'staging', 'approve', 'prod', 'end'] }, + { name: 'Rollback → abandon', nodes: ['start', 'lint', 'test', 'build', 'staging', 'e2e', 'approve', 'prod', 'rollback', 'end'] }, + ], + }, + }, + { + id: 'loan-approval', + name: 'Loan Approval', + description: 'Bank loan application process · 5,120 applications', + graph: { + nodes: [ + { id: 'start', label: 'Application', count: '5.120', type: 'start', layer: 0, col: 0 }, + { id: 'kyc', label: 'KYC check', count: '5.120', type: 'activity', layer: 1, col: 0 }, + { id: 'credit', label: 'Credit score', count: '4.890', type: 'activity', layer: 2, col: 0 }, + { id: 'manual', label: 'Manual review', count: '1.240', type: 'activity', layer: 3, col: 0 }, + { id: 'auto-ok', label: 'Auto approved', count: '3.650', type: 'activity', layer: 3, col: 1 }, + { id: 'collateral', label: 'Collateral check', count: '890', type: 'activity', layer: 4, col: 0 }, + { id: 'approved', label: 'Loan approved', count: '4.210', type: 'activity', layer: 4, col: 1 }, + { id: 'declined', label: 'Loan declined', count: '910', type: 'activity', layer: 5, col: 0 }, + { id: 'disburse', label: 'Disbursement', count: '4.210', type: 'activity', layer: 5, col: 1 }, + { id: 'end', label: 'Case closed', count: '5.120', type: 'end', layer: 6, col: 0 }, + ], + edges: [ + { from: 'start', to: 'kyc', label: '5.120', weight: 5120 }, + { from: 'kyc', to: 'credit', label: '4.890', weight: 4890 }, + { from: 'kyc', to: 'declined', label: '230', weight: 230 }, + { from: 'credit', to: 'manual', label: '1.240', weight: 1240 }, + { from: 'credit', to: 'auto-ok', label: '3.650', weight: 3650 }, + { from: 'manual', to: 'collateral', label: '890', weight: 890 }, + { from: 'manual', to: 'approved', label: '350', weight: 350 }, + { from: 'manual', to: 'declined', label: '0', weight: 50 }, + { from: 'auto-ok', to: 'approved', label: '3.650', weight: 3650 }, + { from: 'collateral', to: 'approved', label: '760', weight: 760 }, + { from: 'collateral', to: 'declined', label: '130', weight: 130 }, + { from: 'approved', to: 'disburse', label: '4.210', weight: 4210 }, + { from: 'declined', to: 'end', label: '910', weight: 910 }, + { from: 'disburse', to: 'end', label: '4.210', weight: 4210 }, + ], + traces: [ + { name: 'Auto-approved fast path', nodes: ['start', 'kyc', 'credit', 'auto-ok', 'approved', 'disburse', 'end'] }, + { name: 'Manual review → approved', nodes: ['start', 'kyc', 'credit', 'manual', 'approved', 'disburse', 'end'] }, + { name: 'Collateral required', nodes: ['start', 'kyc', 'credit', 'manual', 'collateral', 'approved', 'disburse', 'end'] }, + { name: 'Collateral insufficient', nodes: ['start', 'kyc', 'credit', 'manual', 'collateral', 'declined', 'end'] }, + { name: 'KYC failure → declined', nodes: ['start', 'kyc', 'declined', 'end'] }, + { name: 'Manual review → declined', nodes: ['start', 'kyc', 'credit', 'manual', 'declined', 'end'] }, + ], + }, + }, + { + id: 'support-ticket', + name: 'Support Ticket', + description: 'Customer support ticket lifecycle · 8,750 tickets', + graph: { + nodes: [ + { id: 'start', label: 'Ticket opened', count: '8.750', type: 'start', layer: 0, col: 0 }, + { id: 'triage', label: 'Triage', count: '8.750', type: 'activity', layer: 1, col: 0 }, + { id: 'l1', label: 'L1 support', count: '6.300', type: 'activity', layer: 2, col: 0 }, + { id: 'l2', label: 'L2 escalation', count: '2.450', type: 'activity', layer: 2, col: 1 }, + { id: 'resolved', label: 'Resolved', count: '5.890', type: 'activity', layer: 3, col: 0 }, + { id: 'l3', label: 'L3 engineering', count: '1.120', type: 'activity', layer: 3, col: 1 }, + { id: 'reopen', label: 'Reopened', count: '940', type: 'activity', layer: 4, col: 0 }, + { id: 'fix', label: 'Bug fix deployed', count: '870', type: 'activity', layer: 4, col: 1 }, + { id: 'csat', label: 'CSAT survey', count: '7.810', type: 'activity', layer: 5, col: 0 }, + { id: 'end', label: 'Ticket closed', count: '8.750', type: 'end', layer: 6, col: 0 }, + ], + edges: [ + { from: 'start', to: 'triage', label: '8.750', weight: 8750 }, + { from: 'triage', to: 'l1', label: '6.300', weight: 6300 }, + { from: 'triage', to: 'l2', label: '2.450', weight: 2450 }, + { from: 'l1', to: 'resolved', label: '5.890', weight: 5890 }, + { from: 'l1', to: 'l2', label: '410', weight: 410 }, + { from: 'l2', to: 'resolved', label: '1.740', weight: 1740 }, + { from: 'l2', to: 'l3', label: '1.120', weight: 1120 }, + { from: 'resolved', to: 'reopen', label: '940', weight: 940 }, + { from: 'resolved', to: 'csat', label: '4.950', weight: 4950 }, + { from: 'l3', to: 'fix', label: '870', weight: 870 }, + { from: 'l3', to: 'resolved', label: '250', weight: 250 }, + { from: 'reopen', to: 'l1', label: '580', weight: 580 }, + { from: 'reopen', to: 'l2', label: '360', weight: 360 }, + { from: 'fix', to: 'resolved', label: '870', weight: 870 }, + { from: 'csat', to: 'end', label: '7.810', weight: 7810 }, + { from: 'resolved', to: 'end', label: '2.860', weight: 2860 }, + ], + traces: [ + { name: 'L1 quick resolve', nodes: ['start', 'triage', 'l1', 'resolved', 'csat', 'end'] }, + { name: 'L2 escalation → resolved', nodes: ['start', 'triage', 'l2', 'resolved', 'csat', 'end'] }, + { name: 'Full escalation to L3', nodes: ['start', 'triage', 'l1', 'l2', 'l3', 'fix', 'resolved', 'csat', 'end'] }, + { name: 'Resolved then reopened', nodes: ['start', 'triage', 'l1', 'resolved', 'reopen', 'l1', 'resolved', 'csat', 'end'] }, + { name: 'L3 → closed no survey', nodes: ['start', 'triage', 'l2', 'l3', 'resolved', 'end'] }, + { name: 'Reopen escalates to L3', nodes: ['start', 'triage', 'l1', 'resolved', 'reopen', 'l2', 'l3', 'fix', 'resolved', 'csat', 'end'] }, + ], + }, + }, + { + id: 'recipe', + name: 'Pizza Order (Restaurant)', + description: 'Restaurant order from entry to table · 620 orders', + graph: { + nodes: [ + { id: 'start', label: 'Guest seated', count: '620', type: 'start', layer: 0, col: 0 }, + { id: 'order', label: 'Order taken', count: '620', type: 'activity', layer: 1, col: 0 }, + { id: 'kitchen', label: 'In kitchen', count: '620', type: 'activity', layer: 2, col: 0 }, + { id: 'modify', label: 'Order modified', count: '88', type: 'activity', layer: 2, col: 1 }, + { id: 'ready', label: 'Food ready', count: '620', type: 'activity', layer: 3, col: 0 }, + { id: 'serve', label: 'Served', count: '620', type: 'activity', layer: 4, col: 0 }, + { id: 'complaint', label: 'Complaint', count: '34', type: 'activity', layer: 5, col: 0 }, + { id: 'bill', label: 'Bill requested', count: '620', type: 'activity', layer: 5, col: 1 }, + { id: 'end', label: 'Guest leaves', count: '620', type: 'end', layer: 6, col: 0 }, + ], + edges: [ + { from: 'start', to: 'order', label: '620', weight: 620 }, + { from: 'order', to: 'kitchen', label: '620', weight: 620 }, + { from: 'kitchen', to: 'modify', label: '88', weight: 88 }, + { from: 'modify', to: 'kitchen', label: '88', weight: 88 }, + { from: 'kitchen', to: 'ready', label: '620', weight: 620 }, + { from: 'ready', to: 'serve', label: '620', weight: 620 }, + { from: 'serve', to: 'complaint', label: '34', weight: 34 }, + { from: 'serve', to: 'bill', label: '586', weight: 586 }, + { from: 'complaint', to: 'kitchen', label: '24', weight: 24 }, + { from: 'complaint', to: 'bill', label: '10', weight: 10 }, + { from: 'bill', to: 'end', label: '620', weight: 620 }, + ], + traces: [ + { name: 'Smooth dinner', nodes: ['start', 'order', 'kitchen', 'ready', 'serve', 'bill', 'end'] }, + { name: 'Order modified once', nodes: ['start', 'order', 'kitchen', 'modify', 'kitchen', 'ready', 'serve', 'bill', 'end'] }, + { name: 'Complaint handled', nodes: ['start', 'order', 'kitchen', 'ready', 'serve', 'complaint', 'bill', 'end'] }, + { name: 'Complaint → remake', nodes: ['start', 'order', 'kitchen', 'ready', 'serve', 'complaint', 'kitchen', 'ready', 'serve', 'bill', 'end'] }, + { name: 'Multiple modifications', nodes: ['start', 'order', 'kitchen', 'modify', 'kitchen', 'modify', 'kitchen', 'ready', 'serve', 'bill', 'end'] }, + ], + }, + }, +] + +export function getProcessModelLibraryEntry(id: string): ProcessModelLibraryEntry | undefined { + return processModelLibrary.find((e) => e.id === id) +} diff --git a/src/components/display-and-visualization/process-model/types.ts b/src/components/display-and-visualization/process-model/types.ts new file mode 100644 index 0000000..33d4478 --- /dev/null +++ b/src/components/display-and-visualization/process-model/types.ts @@ -0,0 +1,76 @@ +export type ProcessModelTerminalKind = 'start' | 'end' + +export type ProcessModelActivityIconKind = 'plus' | 'check' + +export type ProcessModelNodeBase = { + id: string, + label: string, + count: string, + layer: number, + col: number, +} + +export type ProcessModelGraphTerminalNode = ProcessModelNodeBase & { + type: ProcessModelTerminalKind, +} + +export type ProcessModelGraphActivityNode = ProcessModelNodeBase & { + type: 'activity', + activityIcon?: ProcessModelActivityIconKind, +} + +export type ProcessModelGraphNode = ProcessModelGraphTerminalNode | ProcessModelGraphActivityNode + +export type ProcessModelEdge = { + from: string, + to: string, + label: string, + weight: number, +} + +export type ProcessModelTrace = { + name: string, + nodes: string[], +} + +export type ProcessModelGraph = { + nodes: ProcessModelGraphNode[], + edges: ProcessModelEdge[], + traces?: ProcessModelTrace[], +} + +export type ProcessModelGraphWithTraces = ProcessModelGraph & { + traces: ProcessModelTrace[], +} + +export type ProcessModelLibraryEntry = { + id: string, + name: string, + description: string, + graph: ProcessModelGraph, +} + +export type ProcessModelNodePosition = { + x: number, + y: number, + w: number, + h: number, + layer: number, +} + +export type ProcessModelLayoutResult = { + positions: Record, + canvasW: number, + canvasH: number, +} + +export type ProcessModelEdgePointResult = { + pathD: string, + labelPt: { x: number, y: number }, +} + +export type ProcessModelEdgeStrokeStyle = { + opacity: number, + sw: number, + markerTier: 'strong' | 'medium' | 'faint', +} diff --git a/src/style/theme/colors/component.css b/src/style/theme/colors/component.css index e34a01a..a344c72 100644 --- a/src/style/theme/colors/component.css +++ b/src/style/theme/colors/component.css @@ -26,6 +26,20 @@ --color-progress-indicator-fill: var(--color-primary); --color-progress-indicator-background: var(--color-gray-300); + /* Process model */ + --color-process-model-edge-stroke: var(--color-primary); + --color-process-model-edge-label-bg: var(--color-gray-100); + --color-process-model-edge-label-text-strong: var(--color-primary); + --color-process-model-edge-label-text-muted: var(--color-purple-300); + --color-process-model-terminal-fill: var(--color-primary); + --color-process-model-terminal-fill-active: var(--color-primary-hover); + --color-process-model-terminal-fill-visited: var(--color-purple-400); + --color-process-model-activity-icon-bg: var(--color-purple-100); + --color-process-model-node-active-ring: var(--color-purple-100); + --color-process-model-node-active-bg: var(--color-purple-50); + --color-process-model-node-visited-border: var(--color-purple-200); + --color-process-model-node-visited-bg: #fdf9ff; + /* Property */ --color-property-title-background: var(--color-gray-100); --color-property-title-text: var(--color-text-secondary); @@ -75,6 +89,12 @@ /* ProgressIndicator */ --color-progress-indicator-background: var(--color-gray-700); + /* Process model */ + --color-process-model-edge-label-bg: var(--color-gray-700); + --color-process-model-edge-label-text-muted: var(--color-purple-300); + --color-process-model-node-visited-bg: var(--color-gray-750); + --color-process-model-node-active-bg: var(--color-gray-700); + /* Overlay, Dialog, Modal */ --color-overlay-shadow: #00000060; diff --git a/src/style/theme/components/index.css b/src/style/theme/components/index.css index 2b30c1c..3368fd0 100644 --- a/src/style/theme/components/index.css +++ b/src/style/theme/components/index.css @@ -27,4 +27,5 @@ @import "./combobox.css"; @import "./general.css"; @import "./icon-button.css"; -@import "./date-time-input.css"; \ No newline at end of file +@import "./date-time-input.css"; +@import "./process-model.css"; \ No newline at end of file diff --git a/src/style/theme/components/process-model.css b/src/style/theme/components/process-model.css new file mode 100644 index 0000000..384e25b --- /dev/null +++ b/src/style/theme/components/process-model.css @@ -0,0 +1,180 @@ +@layer components { + .process-model-canvas-wrap { + @apply relative overflow-visible rounded-2xl bg-surface p-8 shadow-md; + } + + .process-model-svg { + @apply block overflow-visible; + } + + .process-model-edge-path { + stroke: var(--color-process-model-edge-stroke); + } + + .process-model-edge-marker-fill { + fill: var(--color-process-model-edge-stroke); + } + + .process-model-edge-label-bg { + fill: var(--color-process-model-edge-label-bg); + } + + .process-model-edge-label-text { + font-family: var(--font-inter); + } + + .process-model-edge-label-text-strong { + fill: var(--color-process-model-edge-label-text-strong); + } + + .process-model-edge-label-text-muted { + fill: var(--color-process-model-edge-label-text-muted); + } + + .process-model-foreign-object { + overflow: visible; + } + + .process-model-foreign-object-inner { + width: 100%; + height: 100%; + } + + .process-model-foreign-object-inner .process-model-activity-node { + @apply w-full min-w-0; + } + + .process-model-activity-node { + @apply flex h-full w-max min-w-[120px] max-w-full cursor-default select-none items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2.5 transition-[border-color,box-shadow,background-color] duration-200 ease-out; + font-family: var(--font-inter); + } + + .process-model-activity-node:not([data-bordered]) { + @apply border-transparent; + } + + .process-model-activity-node[data-interactive] { + @apply cursor-pointer; + } + + .process-model-activity-node[data-interactive]:focus-visible { + @apply outline-2 outline-offset-2 outline-primary; + } + + .process-model-activity-node[data-active] { + border-color: var(--color-purple-400); + box-shadow: 0 0 0 3px var(--color-process-model-node-active-ring); + background-color: var(--color-process-model-node-active-bg); + } + + .process-model-activity-node[data-visited]:not([data-active]) { + border-color: var(--color-process-model-node-visited-border); + background-color: var(--color-process-model-node-visited-bg); + } + + .process-model-activity-node-icon { + @apply flex h-7 w-7 shrink-0 items-center justify-center rounded-lg; + background-color: var(--color-process-model-activity-icon-bg); + } + + .process-model-activity-node-icon-svg { + @apply text-primary; + } + + .process-model-activity-node-text { + @apply flex min-w-0 flex-1 flex-col; + } + + .process-model-activity-node-label { + @apply whitespace-nowrap text-xs font-semibold leading-tight text-on-surface; + } + + .process-model-activity-node-count { + @apply mt-px whitespace-nowrap text-[11px] font-medium text-description; + } + + .process-model-activity-node[data-kind="terminal"] { + @apply gap-3; + } + + .process-model-activity-node[data-kind="terminal"] .process-model-activity-node-icon { + @apply h-auto w-auto rounded-none bg-transparent p-0; + } + + .process-model-activity-node[data-kind="terminal"] .process-model-activity-node-label { + @apply whitespace-nowrap text-sm font-bold leading-snug text-on-surface; + } + + .process-model-activity-node[data-kind="terminal"] .process-model-activity-node-count { + @apply mt-0.5 whitespace-nowrap text-xs font-medium text-description; + } + + .process-model-terminal-node-hex-fill { + fill: var(--color-process-model-terminal-fill); + } + + .process-model-activity-node[data-kind="terminal"][data-active] .process-model-terminal-node-hex-fill { + fill: var(--color-process-model-terminal-fill-active); + } + + .process-model-activity-node[data-kind="terminal"][data-visited]:not([data-active]) .process-model-terminal-node-hex-fill { + fill: var(--color-process-model-terminal-fill-visited); + } + + .process-model-trace-replay { + @apply flex w-full max-w-[900px] flex-col gap-4; + } + + .process-model-trace-replay-toolbar { + @apply flex flex-row flex-wrap items-center justify-center gap-2; + } + + .process-model-trace-replay-btn { + @apply flex cursor-pointer flex-row items-center gap-1.5 rounded-md border-0 px-3.5 py-1.5 text-xs font-semibold transition-colors duration-150; + font-family: var(--font-inter); + } + + .process-model-trace-replay-btn-primary { + @apply bg-primary text-on-primary hover:bg-primary-hover; + } + + .process-model-trace-replay-btn-secondary { + @apply border border-border bg-surface text-on-surface hover:bg-surface-hover; + } + + .process-model-trace-replay-speed { + @apply flex flex-row items-center gap-2 text-xs font-medium text-description; + } + + .process-model-trace-replay-select { + @apply cursor-pointer rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-on-surface; + } + + .process-model-trace-log { + @apply w-full overflow-hidden rounded-lg bg-surface shadow-md; + } + + .process-model-trace-log-header { + @apply border-b border-border bg-surface-variant px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wide text-description; + } + + .process-model-trace-log-body { + @apply max-h-[110px] overflow-y-auto py-1.5; + } + + .process-model-trace-log-placeholder { + @apply px-4 py-1.5 text-[11px] italic text-description; + } + + .process-model-trace-log-event { + @apply flex flex-row items-center gap-2 px-4 py-1 text-[11px] text-on-surface; + } + + .process-model-trace-log-dot { + @apply h-1.5 w-1.5 shrink-0 rounded-full bg-primary; + } + + .process-model-trace-log-time { + @apply min-w-[36px] shrink-0 text-[10px] text-description; + } +} diff --git a/stories/Display And Visualization/ProcessModel/ProcessModelActivityNode.stories.tsx b/stories/Display And Visualization/ProcessModel/ProcessModelActivityNode.stories.tsx new file mode 100644 index 0000000..c79ebfe --- /dev/null +++ b/stories/Display And Visualization/ProcessModel/ProcessModelActivityNode.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { Plus } from 'lucide-react' +import { action } from 'storybook/actions' +import { ProcessModelActivityNode } from '@/src/components/display-and-visualization/process-model/ProcessModelActivityNode' + +const meta: Meta = { + component: ProcessModelActivityNode, +} + +export default meta +type Story = StoryObj; + +export const processModelActivityNode: Story = { + args: { + nodeId: 'demo-activity', + label: 'Task added', + count: '172', + active: false, + visited: false, + className: '', + customIcon: , + onClick: action('onClick'), + onPointerEnter: action('onPointerEnter'), + onPointerLeave: action('onPointerLeave'), + }, + render: (args) => ( +
+
+ +
+
+ ), +} diff --git a/stories/Display And Visualization/ProcessModel/ProcessModelCanvas.stories.tsx b/stories/Display And Visualization/ProcessModel/ProcessModelCanvas.stories.tsx new file mode 100644 index 0000000..66378c9 --- /dev/null +++ b/stories/Display And Visualization/ProcessModel/ProcessModelCanvas.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import { ProcessModelCanvas } from '@/src/components/display-and-visualization/process-model/ProcessModelCanvas' +import { + getProcessModelLibraryEntry, + processModelLibrary +} from '@/src/components/display-and-visualization/process-model/processModelLibrary' + +const meta: Meta = { + component: ProcessModelCanvas, +} + +export default meta +type Story = StoryObj; + +export const processModelCanvas: Story = { + render: () => { + const [modelId, setModelId] = useState(processModelLibrary[0].id) + const graph = useMemo( + () => getProcessModelLibraryEntry(modelId)?.graph ?? processModelLibrary[0].graph, + [modelId] + ) + return ( +
+ + +
+ ) + }, +} + +export const processModelCanvasHighlighted: Story = { + render: () => ( + + ), +} diff --git a/stories/Display And Visualization/ProcessModel/ProcessModelTraceReplay.stories.tsx b/stories/Display And Visualization/ProcessModel/ProcessModelTraceReplay.stories.tsx new file mode 100644 index 0000000..31e9f87 --- /dev/null +++ b/stories/Display And Visualization/ProcessModel/ProcessModelTraceReplay.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import { ProcessModelTraceReplay } from '@/src/components/display-and-visualization/process-model/ProcessModelTraceReplay' +import { + getProcessModelLibraryEntry, + processModelLibrary +} from '@/src/components/display-and-visualization/process-model/processModelLibrary' +import type { ProcessModelGraphWithTraces } from '@/src/components/display-and-visualization/process-model/types' + +const meta: Meta = { + component: ProcessModelTraceReplay, +} + +export default meta +type Story = StoryObj; + +export const processModelTraceReplay: Story = { + render: () => { + const [modelId, setModelId] = useState(processModelLibrary[0].id) + const graph = useMemo(() => { + const g = getProcessModelLibraryEntry(modelId)?.graph + return g as ProcessModelGraphWithTraces + }, [modelId]) + return ( +
+ + +
+ ) + }, +} diff --git a/tests/process-model/layoutProcessModel.test.ts b/tests/process-model/layoutProcessModel.test.ts new file mode 100644 index 0000000..bad5878 --- /dev/null +++ b/tests/process-model/layoutProcessModel.test.ts @@ -0,0 +1,166 @@ +import { + computeLayout, + estimateProcessModelActivityChromeWidth, + estimateProcessModelActivityNodeWidth, + getEdgePoints, + getProcessModelEdgePathDomId, + maxEdgeWeight, + terminalCountDisplayLine, + weightToStyle +} from '../../src/components/display-and-visualization/process-model/layoutProcessModel' +import type { ProcessModelGraph } from '../../src/components/display-and-visualization/process-model/types' + +const verticalTwoNodeGraph: ProcessModelGraph = { + nodes: [ + { id: 'a', label: 'Start', count: '1', type: 'start', layer: 0, col: 0 }, + { id: 'b', label: 'End', count: '1', type: 'end', layer: 1, col: 0 }, + ], + edges: [{ from: 'a', to: 'b', label: '1', weight: 1 }], +} + +const activity = ( + id: string, + label: string, + count: string, + layer: number, + col: number +) => ({ + id, + type: 'activity' as const, + label, + count, + layer, + col, +}) + +describe('estimateProcessModelActivityNodeWidth', () => { + test('grows with longer label text', () => { + const short = estimateProcessModelActivityNodeWidth('A', '1') + const long = estimateProcessModelActivityNodeWidth('Very long activity step name', '1') + expect(long).toBeGreaterThan(short) + expect(short).toBeGreaterThanOrEqual(120) + }) +}) + +describe('estimateProcessModelActivityChromeWidth', () => { + test('terminal width tracks label and count line like the activity node', () => { + const narrow = estimateProcessModelActivityChromeWidth( + 'terminal', + 'Start', + terminalCountDisplayLine('1') + ) + const wide = estimateProcessModelActivityChromeWidth( + 'terminal', + 'Very long start or end node title', + terminalCountDisplayLine('99999') + ) + expect(wide).toBeGreaterThan(narrow) + expect(narrow).toBeGreaterThanOrEqual(120) + expect(narrow).toBeLessThan(280) + }) +}) + +describe('computeLayout', () => { + test('assigns positions for each node id', () => { + const { positions, canvasW, canvasH } = computeLayout(verticalTwoNodeGraph) + expect(positions.a).toBeDefined() + expect(positions.b).toBeDefined() + expect(positions.a.w).toBeGreaterThan(0) + expect(positions.b.w).toBeGreaterThan(0) + expect(canvasW).toBeGreaterThan(0) + expect(canvasH).toBeGreaterThan(0) + expect(positions.b.y).toBeGreaterThan(positions.a.y) + }) + + test('uses wider foreignObject for longer activity labels', () => { + const shortGraph: ProcessModelGraph = { + nodes: [ + { id: 's', label: 'Start', count: '1', type: 'start', layer: 0, col: 0 }, + activity('m', 'Hi', '1', 0, 1), + { id: 'e', label: 'End', count: '1', type: 'end', layer: 0, col: 2 }, + ], + edges: [], + } + const longGraph: ProcessModelGraph = { + nodes: [ + { id: 's', label: 'Start', count: '1', type: 'start', layer: 0, col: 0 }, + activity('m', 'Much longer middle activity label', '999', 0, 1), + { id: 'e', label: 'End', count: '1', type: 'end', layer: 0, col: 2 }, + ], + edges: [], + } + const wShort = computeLayout(shortGraph).positions.m.w + const wLong = computeLayout(longGraph).positions.m.w + expect(wLong).toBeGreaterThan(wShort) + }) +}) + +describe('getEdgePoints', () => { + test('returns vertical path for stacked nodes', () => { + const { positions } = computeLayout(verticalTwoNodeGraph) + const pts = getEdgePoints(positions, 'a', 'b', verticalTwoNodeGraph.edges) + expect(pts).not.toBeNull() + expect(pts?.pathD).toMatch(/^M[\d.-]+,[\d.-]+\s+L[\d.-]+,[\d.-]+$/) + expect(pts?.labelPt.x).toBeDefined() + expect(pts?.labelPt.y).toBeDefined() + }) + + test('returns null when node id is missing', () => { + const { positions } = computeLayout(verticalTwoNodeGraph) + expect(getEdgePoints(positions, 'a', 'missing', verticalTwoNodeGraph.edges)).toBeNull() + }) + + test('curved skip edge ends with a straight segment for the marker', () => { + const skipGraph: ProcessModelGraph = { + nodes: [ + { id: 's', label: 'Start', count: '1', type: 'start', layer: 0, col: 0 }, + { id: 'e', label: 'End', count: '1', type: 'end', layer: 2, col: 0 }, + ], + edges: [{ from: 's', to: 'e', label: '1', weight: 1 }], + } + const { positions } = computeLayout(skipGraph) + const pts = getEdgePoints(positions, 's', 'e', skipGraph.edges) + expect(pts).not.toBeNull() + expect(pts?.pathD).toContain(' C') + expect(pts?.pathD).toMatch(/\s+L[\d.-]+,[\d.-]+$/) + }) +}) + +describe('maxEdgeWeight', () => { + test('returns max of weights', () => { + expect(maxEdgeWeight([{ from: 'a', to: 'b', label: 'x', weight: 3 }, { from: 'b', to: 'c', label: 'y', weight: 10 }])).toBe(10) + }) + + test('returns 1 for empty edges', () => { + expect(maxEdgeWeight([])).toBe(1) + }) +}) + +describe('getProcessModelEdgePathDomId', () => { + test('prefixes id when prefix is set', () => { + expect(getProcessModelEdgePathDomId('p1', 'a', 'b')).toBe('ep-p1-a-b') + }) + + test('omits prefix segment when prefix is empty', () => { + expect(getProcessModelEdgePathDomId('', 'a', 'b')).toBe('ep-a-b') + expect(getProcessModelEdgePathDomId(undefined, 'a', 'b')).toBe('ep-a-b') + }) +}) + +describe('weightToStyle', () => { + test('maps high ratio to strong marker tier', () => { + const s = weightToStyle(80, 100) + expect(s.markerTier).toBe('strong') + expect(s.opacity).toBe(1) + }) + + test('maps mid ratio to medium', () => { + const s = weightToStyle(40, 100) + expect(s.markerTier).toBe('medium') + }) + + test('maps low ratio to faint', () => { + const s = weightToStyle(10, 100) + expect(s.markerTier).toBe('faint') + }) +})