From 9568369ed4e36bf9408b167b6b5af86925fb70d5 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 26 Aug 2026 13:12:50 +0200 Subject: [PATCH 1/2] Improve devtools statechart layout --- .changeset/quiet-charts-route.md | 7 + .../internal/browser/chart-layout-policy.ts | 159 ++++++++++++ .../src/internal/browser/chart-layout.ts | 227 +++++++++++++++--- .../src/internal/browser/chart-model.ts | 10 + .../src/internal/browser/chart-renderer.ts | 25 +- .../devtools/src/internal/browser/styles.css | 76 +++++- .../src/internal/browser/visualizer-app.ts | 2 +- .../test/internal/browser/StaticChart.test.ts | 198 ++++++++++++++- 8 files changed, 654 insertions(+), 50 deletions(-) create mode 100644 .changeset/quiet-charts-route.md create mode 100644 packages/devtools/src/internal/browser/chart-layout-policy.ts diff --git a/.changeset/quiet-charts-route.md b/.changeset/quiet-charts-route.md new file mode 100644 index 0000000..2bbfe73 --- /dev/null +++ b/.changeset/quiet-charts-route.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine-devtools": patch +--- + +Improve the static statechart so initial and reachable states follow a stable left-to-right order, reverse and self-transitions use dedicated routes, and transition labels stay clear of arrowheads and compound boundaries. + +Distinguish automatic transitions with dashed lines and correlate invoke outcomes with muted colors for effect, timer, stream, process, and child-machine activities. diff --git a/packages/devtools/src/internal/browser/chart-layout-policy.ts b/packages/devtools/src/internal/browser/chart-layout-policy.ts new file mode 100644 index 0000000..fe847fa --- /dev/null +++ b/packages/devtools/src/internal/browser/chart-layout-policy.ts @@ -0,0 +1,159 @@ +import type { ChartEdge, ChartModel, ChartNode } from "./chart-model.js" + +export type ChartPortSide = "NORTH" | "EAST" | "SOUTH" | "WEST" + +export interface ChartNodeLayoutPolicy { + readonly reachable: boolean + readonly rank: number | null + readonly order: number + readonly layerConstraint: "LAST" | null +} + +export interface ChartEdgeLayoutPolicy { + readonly direction: "forward" | "backward" | "self" + readonly sourceSide: ChartPortSide + readonly targetSide: ChartPortSide +} + +export interface ChartLayoutPolicy { + readonly children: (parent: string | null) => ReadonlyArray + readonly node: (path: string) => ChartNodeLayoutPolicy + readonly edge: (edge: ChartEdge) => ChartEdgeLayoutPolicy +} + +const unreachableNode: ChartNodeLayoutPolicy = { + reachable: false, + rank: null, + order: Number.MAX_SAFE_INTEGER, + layerConstraint: null +} + +const directChild = ( + path: string, + parent: string | null, + nodes: ReadonlyMap +): string | null => { + let current = nodes.get(path) + while (current !== undefined && current.parent !== parent) { + current = current.parent === null ? undefined : nodes.get(current.parent) + } + return current?.path ?? null +} + +const lineage = (path: string, nodes: ReadonlyMap): ReadonlyArray => { + const result: Array = [] + let current = nodes.get(path) + while (current !== undefined) { + result.push(current.path) + current = current.parent === null ? undefined : nodes.get(current.parent) + } + return result.reverse() +} + +export const makeChartLayoutPolicy = (model: ChartModel): ChartLayoutPolicy => { + const nodes = new Map(model.nodes.map((node) => [node.path, node])) + const declarationOrder = new Map(model.nodes.map((node, index) => [node.path, index])) + const childrenByParent = new Map>() + for (const node of model.nodes) { + const children = childrenByParent.get(node.parent) ?? [] + children.push(node) + childrenByParent.set(node.parent, children) + } + + const nodePolicies = new Map() + const orderedChildren = new Map>() + + for (const [parent, children] of childrenByParent) { + const childPaths = new Set(children.map(({ path }) => path)) + const adjacency = new Map(children.map(({ path }) => [path, new Set()])) + for (const edge of model.edges) { + if (edge.kind !== "target" || edge.target === null) continue + const source = directChild(edge.source, parent, nodes) + const target = directChild(edge.target, parent, nodes) + if (source !== null && target !== null && source !== target && childPaths.has(source) && childPaths.has(target)) { + adjacency.get(source)?.add(target) + } + } + + const ranks = new Map() + const queue: Array = [] + for (const initial of model.initials) { + if (initial.parent !== parent) continue + const target = directChild(initial.target, parent, nodes) + if (target !== null && childPaths.has(target) && !ranks.has(target)) { + ranks.set(target, 0) + queue.push(target) + } + } + for (let index = 0; index < queue.length; index++) { + const source = queue[index]! + const nextRank = ranks.get(source)! + 1 + for (const target of adjacency.get(source) ?? []) { + const current = ranks.get(target) + if (current === undefined || nextRank < current) { + ranks.set(target, nextRank) + queue.push(target) + } + } + } + + const ordered = [...children].sort((left, right) => { + const leftRank = ranks.get(left.path) + const rightRank = ranks.get(right.path) + if (leftRank !== undefined && rightRank === undefined) return -1 + if (leftRank === undefined && rightRank !== undefined) return 1 + if (leftRank !== undefined && rightRank !== undefined && leftRank !== rightRank) return leftRank - rightRank + return declarationOrder.get(left.path)! - declarationOrder.get(right.path)! + }) + orderedChildren.set(parent, ordered) + ordered.forEach((node, order) => { + const rank = ranks.get(node.path) ?? null + nodePolicies.set(node.path, { + reachable: rank !== null, + rank, + order, + layerConstraint: node.type === "final" ? "LAST" : null + }) + }) + } + + const edgePolicy = (edge: ChartEdge): ChartEdgeLayoutPolicy => { + if (edge.kind === "targetless" || edge.target === edge.source) { + return { direction: "self", sourceSide: "SOUTH", targetSide: "SOUTH" } + } + if (edge.target === null) { + return { direction: "forward", sourceSide: "EAST", targetSide: "WEST" } + } + + const sourceLineage = lineage(edge.source, nodes) + const targetLineage = lineage(edge.target, nodes) + let differentAt = 0 + while ( + differentAt < sourceLineage.length && differentAt < targetLineage.length && + sourceLineage[differentAt] === targetLineage[differentAt] + ) { + differentAt++ + } + if (differentAt === sourceLineage.length) { + return { direction: "forward", sourceSide: "EAST", targetSide: "WEST" } + } + if (differentAt === targetLineage.length) { + return { direction: "backward", sourceSide: "WEST", targetSide: "EAST" } + } + + const source = nodePolicies.get(sourceLineage[differentAt]!) ?? unreachableNode + const target = nodePolicies.get(targetLineage[differentAt]!) ?? unreachableNode + const backward = source.rank !== null && target.rank !== null + ? target.rank < source.rank || target.rank === source.rank && target.order < source.order + : target.order < source.order + return backward + ? { direction: "backward", sourceSide: "WEST", targetSide: "EAST" } + : { direction: "forward", sourceSide: "EAST", targetSide: "WEST" } + } + + return { + children: (parent) => orderedChildren.get(parent) ?? [], + node: (path) => nodePolicies.get(path) ?? unreachableNode, + edge: edgePolicy + } +} diff --git a/packages/devtools/src/internal/browser/chart-layout.ts b/packages/devtools/src/internal/browser/chart-layout.ts index f44d25f..8328b54 100644 --- a/packages/devtools/src/internal/browser/chart-layout.ts +++ b/packages/devtools/src/internal/browser/chart-layout.ts @@ -9,6 +9,7 @@ import type { ElkPort } from "elkjs/lib/elk-api.js" import ELKBundle from "elkjs/lib/elk.bundled.js" +import { type ChartPortSide, makeChartLayoutPolicy } from "./chart-layout-policy.js" import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js" export const maxVisibleFields = 4 @@ -108,29 +109,33 @@ const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}` const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target` const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}` const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target` +const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source -const portsByState = (model: ChartModel): ReadonlyMap> => { +const portsByState = ( + model: ChartModel, + edgePolicy: ReturnType["edge"] +): ReadonlyMap> => { const ports = new Map>() - const add = (path: string, id: string, side: "EAST" | "WEST"): void => { + const add = (path: string, id: string, side: ChartPortSide): void => { const statePorts = ports.get(path) ?? [] statePorts.push({ id, width: 6, height: 6, layoutOptions: { - "elk.port.side": side, - "elk.port.index": String(statePorts.length) + "elk.port.side": side } }) ports.set(path, statePorts) } for (const edge of model.edges) { - add(edge.source, sourcePortId(edge), "EAST") + const policy = edgePolicy(edge) + add(edge.source, sourcePortId(edge), policy.sourceSide) if (edge.kind === "target" && edge.target !== null) { - add(edge.target, targetPortId(edge), "WEST") + add(edge.target, targetPortId(edge), policy.targetSide) } else if (edge.kind === "targetless") { - add(edge.source, targetPortId(edge), "EAST") + add(edge.source, targetPortId(edge), policy.targetSide) } } for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST") @@ -143,12 +148,15 @@ const labelMetric = (label: string): { readonly width: number; readonly height: }) const makeGraph = (model: ChartModel): ElkNode => { - const nodesByParent = new Map>() - for (const node of model.nodes) { - const siblings = nodesByParent.get(node.parent) ?? [] - siblings.push(node) - nodesByParent.set(node.parent, siblings) - } + const policy = makeChartLayoutPolicy(model) + const nodesByPath = new Map(model.nodes.map((node) => [node.path, node])) + const selfLoopParents = new Set( + model.edges.flatMap((edge) => { + if (!isSelfTransition(edge)) return [] + const parent = nodesByPath.get(edge.source)?.parent + return parent === null || parent === undefined ? [] : [parent] + }) + ) const initialsByParent = new Map>() for (const initial of model.initials) { const siblings = initialsByParent.get(initial.parent) ?? [] @@ -161,43 +169,50 @@ const makeGraph = (model: ChartModel): ElkNode => { siblings.push(target) runtimeTargetsByParent.set(target.parent, siblings) } - const ports = portsByState(model) + const ports = portsByState(model, policy.edge) const children = (parent: string | null): Array => [ ...(initialsByParent.get(parent) ?? []).map((initial): ElkNode => ({ id: initialNodeId(initial), width: 14, - height: 14 + height: 14, + layoutOptions: { + "elk.layered.layering.layerConstraint": "FIRST" + } })), - ...(nodesByParent.get(parent) ?? []).map((node): ElkNode => { + ...policy.children(parent).map((node): ElkNode => { const metric = nodeMetric(node) const descendants = children(node.path) + const nodePolicy = policy.node(node.path) + const bottomPadding = 28 + (selfLoopParents.has(node.path) ? chartSelfLoopParentAllowance : 0) const common = { id: node.path, - ports: [...ports.get(node.path) ?? []] + ports: [...ports.get(node.path) ?? []], + layoutOptions: { + "elk.portConstraints": "FIXED_SIDE", + "elk.spacing.portPort": "22", + ...(nodePolicy.layerConstraint === null + ? {} + : { "elk.layered.layering.layerConstraint": nodePolicy.layerConstraint }) + } } if (descendants.length === 0) { return { ...common, width: metric.width, - height: metric.height, - layoutOptions: { - "elk.portConstraints": "FIXED_ORDER", - "elk.spacing.portPort": "22" - } + height: metric.height } } return { ...common, children: descendants, layoutOptions: { + ...common.layoutOptions, "elk.algorithm": "layered", "elk.direction": "RIGHT", - "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=28,right=28]`, + "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=${bottomPadding},right=28]`, "elk.nodeSize.constraints": "MINIMUM_SIZE", "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`, - "elk.portConstraints": "FIXED_ORDER", - "elk.spacing.portPort": "22", "elk.spacing.nodeNode": "44", "elk.layered.spacing.nodeNodeBetweenLayers": "108" } @@ -223,17 +238,28 @@ const makeGraph = (model: ChartModel): ElkNode => { edges: [ ...model.edges.map((edge): ElkExtendedEdge => { const label = labelMetric(edge.label) + const edgeLayout = policy.edge(edge) return { id: edge.id, sources: [sourcePortId(edge)], targets: [targetPortId(edge)], - labels: [{ text: edge.label, width: label.width, height: label.height }] + labels: [{ text: edge.label, width: label.width, height: label.height }], + layoutOptions: { + "elk.layered.priority.direction": edgeLayout.direction === "forward" ? "10" : "1", + "elk.layered.priority.shortness": "5", + "elk.layered.priority.straightness": "5" + } } }), ...model.initials.map((initial): ElkExtendedEdge => ({ id: initial.id, sources: [initialNodeId(initial)], - targets: [initialTargetPortId(initial)] + targets: [initialTargetPortId(initial)], + layoutOptions: { + "elk.layered.priority.direction": "100", + "elk.layered.priority.shortness": "100", + "elk.layered.priority.straightness": "100" + } })) ], layoutOptions: { @@ -248,7 +274,12 @@ const makeGraph = (model: ChartModel): ElkNode => { "elk.layered.spacing.edgeEdgeBetweenLayers": "26", "elk.spacing.edgeNode": "28", "elk.spacing.edgeEdge": "20", + "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", + "elk.layered.considerModelOrder.portModelOrder": "false", + "elk.layered.considerModelOrder.crossingCounterNodeInfluence": "0.001", + "elk.layered.considerModelOrder.components": "FORCE_MODEL_ORDER", "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.layered.crossingMinimization.hierarchicalSweepiness": "1", "elk.layered.crossingMinimization.greedySwitchHierarchical.type": "TWO_SIDED", "elk.layered.nodePlacement.favorStraightEdges": "true", "elk.layered.mergeHierarchyEdges": "false", @@ -326,10 +357,19 @@ const midpoint = (points: ReadonlyArray): ChartPoint => { return points.at(-1)! } -const expandTargetlessLoop = (points: ReadonlyArray): ReadonlyArray => { +const expandSelfLoop = (points: ReadonlyArray): ReadonlyArray => { const start = points[0] const end = points.at(-1) if (start === undefined || end === undefined) return points + if (Math.abs(start.y - end.y) <= Math.abs(start.x - end.x)) { + const outerY = Math.max(...points.map(({ y }) => y)) + 30 + return compactPoints([ + start, + { x: start.x, y: outerY }, + { x: end.x, y: outerY }, + end + ]) + } const outerX = Math.max(...points.map(({ x }) => x)) + 30 return compactPoints([ start, @@ -339,6 +379,95 @@ const expandTargetlessLoop = (points: ReadonlyArray): ReadonlyArray< ]) } +export const chartEdgeTerminalClearance = 24 +export const chartSelfLoopLabelGap = 8 +export const chartSelfLoopParentAllowance = 44 + +const longestSegment = ( + points: ReadonlyArray, + matches: (start: ChartPoint, end: ChartPoint) => boolean, + length: (start: ChartPoint, end: ChartPoint) => number +): readonly [ChartPoint, ChartPoint] | undefined => { + let result: readonly [ChartPoint, ChartPoint] | undefined + let resultLength = -1 + for (let index = 1; index < points.length; index++) { + const start = points[index - 1]! + const end = points[index]! + if (!matches(start, end)) continue + const candidateLength = length(start, end) + if (candidateLength > resultLength) { + result = [start, end] + resultLength = candidateLength + } + } + return result +} + +export const selfLoopLabelPosition = ( + points: ReadonlyArray, + labelWidth: number, + labelHeight: number +): ChartPoint => { + const start = points[0] + const end = points.at(-1) + if (start === undefined || end === undefined) return midpoint(points) + + if (Math.abs(start.y - end.y) <= Math.abs(start.x - end.x)) { + const outerY = Math.max(...points.map(({ y }) => y)) + const segment = longestSegment( + points, + (left, right) => left.y === outerY && right.y === outerY, + (left, right) => Math.abs(right.x - left.x) + ) + return { + x: segment === undefined ? (start.x + end.x) / 2 : (segment[0].x + segment[1].x) / 2, + y: outerY + chartSelfLoopLabelGap + labelHeight / 2 + } + } + + const outerX = Math.max(...points.map(({ x }) => x)) + const segment = longestSegment( + points, + (top, bottom) => top.x === outerX && bottom.x === outerX, + (top, bottom) => Math.abs(bottom.y - top.y) + ) + return { + x: outerX + chartSelfLoopLabelGap + labelWidth / 2, + y: segment === undefined ? (start.y + end.y) / 2 : (segment[0].y + segment[1].y) / 2 + } +} + +export const ensureChartEdgeTerminalClearance = ( + points: ReadonlyArray +): ReadonlyArray => { + const end = points.at(-1) + const bend = points.at(-2) + if (end === undefined || bend === undefined || points.length < 3) return points + const horizontal = end.y === bend.y + const length = horizontal ? Math.abs(end.x - bend.x) : Math.abs(end.y - bend.y) + if (length >= chartEdgeTerminalClearance) return points + + const result = points.map((point) => ({ ...point })) + if (horizontal) { + const direction = Math.sign(end.x - bend.x) + if (direction === 0) return points + let first = points.length - 2 + while (first > 0 && points[first - 1]!.x === bend.x) first-- + if (first === 0) return points + const x = end.x - direction * chartEdgeTerminalClearance + for (let index = first; index < points.length - 1; index++) result[index]!.x = x + } else { + const direction = Math.sign(end.y - bend.y) + if (direction === 0) return points + let first = points.length - 2 + while (first > 0 && points[first - 1]!.y === bend.y) first-- + if (first === 0) return points + const y = end.y - direction * chartEdgeTerminalClearance + for (let index = first; index < points.length - 1; index++) result[index]!.y = y + } + return compactPoints(result) +} + const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => { const chartNodes = new Map(model.nodes.map((node) => [node.path, node])) const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial])) @@ -397,28 +526,54 @@ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => { if (chartEdge !== undefined) { const metric = labelMetric(chartEdge.label) const label = edge.labels?.[0] - const transitionPoints = chartEdge.kind === "targetless" ? expandTargetlessLoop(points) : points + const selfTransition = isSelfTransition(chartEdge) + const routedPoints = selfTransition + ? expandSelfLoop(points) + : points + const transitionPoints = ensureChartEdgeTerminalClearance(routedPoints) + const labelWidth = label?.width ?? metric.width + const labelHeight = label?.height ?? metric.height return [{ kind: "transition", edge: chartEdge, points: transitionPoints, - label: label?.x === undefined || label.y === undefined + label: selfTransition + ? selfLoopLabelPosition(transitionPoints, labelWidth, labelHeight) + : label?.x === undefined || label.y === undefined ? midpoint(transitionPoints) : add(offset, { - x: label.x + (label.width ?? metric.width) / 2, - y: label.y + (label.height ?? metric.height) / 2 + x: label.x + labelWidth / 2, + y: label.y + labelHeight / 2 }), - labelWidth: label?.width ?? metric.width, - labelHeight: label?.height ?? metric.height + labelWidth, + labelHeight }] } const initial = initialEdges.get(edge.id) return initial === undefined ? [] : [{ kind: "initial", initial, points }] }) + const transitionEdges = edges.filter((edge) => edge.kind === "transition") + const contentWidth = Math.max( + 0, + ...nodes.map(({ width, x }) => x + width), + ...initials.map(({ width, x }) => x + width), + ...runtimeTargets.map(({ width, x }) => x + width), + ...edges.flatMap(({ points }) => points.map(({ x }) => x)), + ...transitionEdges.map(({ label, labelWidth }) => label.x + labelWidth / 2) + ) + const contentHeight = Math.max( + 0, + ...nodes.map(({ height, y }) => y + height), + ...initials.map(({ height, y }) => y + height), + ...runtimeTargets.map(({ height, y }) => y + height), + ...edges.flatMap(({ points }) => points.map(({ y }) => y)), + ...transitionEdges.map(({ label, labelHeight }) => label.y + labelHeight / 2) + ) + return { - width: Math.max(360, graph.width ?? 0), - height: Math.max(280, graph.height ?? 0), + width: Math.max(360, graph.width ?? 0, contentWidth + 20), + height: Math.max(280, graph.height ?? 0, contentHeight + 20), nodes, initials, runtimeTargets, diff --git a/packages/devtools/src/internal/browser/chart-model.ts b/packages/devtools/src/internal/browser/chart-model.ts index d669df0..44fcd52 100644 --- a/packages/devtools/src/internal/browser/chart-model.ts +++ b/packages/devtools/src/internal/browser/chart-model.ts @@ -42,6 +42,7 @@ export interface ChartEdge { readonly target: string | null readonly label: string readonly trigger: VisualizationTransition["trigger"] + readonly activityKind: ChartActivity["kind"] | null readonly reenter: boolean readonly acceptance: VisualizationTransition["acceptance"] } @@ -156,6 +157,12 @@ export const makeChartModel = (document: VisualizationDocument): ChartModel => { const active = new Set(document.snapshot?.activePaths ?? []) const initialPaths = new Set([document.initial.target]) const activities = new Map(document.activities.map((activity) => [activity.id, activity])) + const activitiesBySource = new Map>() + for (const activity of document.activities) { + const sourceActivities = activitiesBySource.get(activity.source) ?? new Map() + sourceActivities.set(activity.lifecycleId, activity.type) + activitiesBySource.set(activity.source, sourceActivities) + } const states = new Map(document.states.map((state) => [state.path, state])) for (const state of document.states) { @@ -200,6 +207,9 @@ export const makeChartModel = (document: VisualizationDocument): ChartModel => { ? transitionLabel(transition, branches[0]!) : `${triggerLabel(transition)} · ${branches.length} branches`, trigger: transition.trigger, + activityKind: transition.trigger.type === "invoke" + ? activitiesBySource.get(transition.source)?.get(transition.trigger.id) ?? null + : null, reenter: transition.reenter, acceptance: transition.acceptance })) diff --git a/packages/devtools/src/internal/browser/chart-renderer.ts b/packages/devtools/src/internal/browser/chart-renderer.ts index b0b5e70..81c66e1 100644 --- a/packages/devtools/src/internal/browser/chart-renderer.ts +++ b/packages/devtools/src/internal/browser/chart-renderer.ts @@ -168,7 +168,7 @@ const stateContent = (layout: LaidOutChartNode, stateStatus: HTMLElement): Docum const activities = element("div", "chart-state-section") activities.append(element("div", "chart-section-label", "invokes")) layout.node.activities.slice(0, maxVisibleActivities).forEach((activity) => { - const row = element("div", "chart-activity-row") + const row = element("div", `chart-activity-row chart-activity-${activity.kind}`) row.append( element("span", "chart-activity-kind", activity.kind), element("span", "chart-activity-name", activity.label) @@ -219,14 +219,15 @@ const render = ( const definitions = svgElement("defs") const marker = svgElement("marker") marker.id = "chart-arrow" - marker.setAttribute("viewBox", "0 0 10 10") - marker.setAttribute("refX", "9") - marker.setAttribute("refY", "5") - marker.setAttribute("markerWidth", "7") - marker.setAttribute("markerHeight", "7") + marker.setAttribute("viewBox", "0 0 12 12") + marker.setAttribute("refX", "11") + marker.setAttribute("refY", "6") + marker.setAttribute("markerWidth", "12") + marker.setAttribute("markerHeight", "12") + marker.setAttribute("markerUnits", "userSpaceOnUse") marker.setAttribute("orient", "auto-start-reverse") const arrow = svgElement("path") - arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z") + arrow.setAttribute("d", "M 1 1 L 11 6 L 1 11 z") marker.append(arrow) definitions.append(marker) svg.append(definitions) @@ -337,7 +338,11 @@ const render = ( const group = svgElement( "g", `chart-edge-group chart-edge-${laidOut.kind}${ - laidOut.kind === "transition" ? ` chart-transition-${laidOut.edge.kind}` : "" + laidOut.kind === "transition" + ? ` chart-transition-${laidOut.edge.kind} chart-edge-trigger-${laidOut.edge.trigger.type}${ + laidOut.edge.activityKind === null ? "" : ` chart-edge-activity-${laidOut.edge.activityKind}` + }` + : "" }` ) const visible = svgElement("path", "chart-edge-line") @@ -354,7 +359,9 @@ const render = ( registerEdgeElement(laidOut.edge.id, group) const label = element( "button", - `chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}`, + `chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}${ + laidOut.edge.activityKind === null ? "" : ` chart-edge-activity-${laidOut.edge.activityKind}` + }`, laidOut.edge.label ) label.type = "button" diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css index cb4705f..53a87d2 100644 --- a/packages/devtools/src/internal/browser/styles.css +++ b/packages/devtools/src/internal/browser/styles.css @@ -558,11 +558,51 @@ button { .chart-activity-kind { min-width: max-content; - color: #77a990; + color: var(--chart-activity-color, #77a990); overflow: visible; text-overflow: clip; } +.chart-activity-process, +.chart-edge-activity-process, +.badge-activity-process { + --chart-activity-color: #a98978; + --chart-activity-border: #51433c; + --chart-activity-background: #191513; +} + +.chart-activity-effect, +.chart-edge-activity-effect, +.badge-activity-effect { + --chart-activity-color: #7f9f8c; + --chart-activity-border: #42564b; + --chart-activity-background: #131815; +} + +.chart-activity-timer, +.chart-edge-activity-timer, +.badge-activity-timer { + --chart-activity-color: #aaa07c; + --chart-activity-border: #57503c; + --chart-activity-background: #191812; +} + +.chart-activity-stream, +.chart-edge-activity-stream, +.badge-activity-stream { + --chart-activity-color: #7898a2; + --chart-activity-border: #405259; + --chart-activity-background: #121719; +} + +.chart-activity-machine, +.chart-edge-activity-machine, +.badge-activity-machine { + --chart-activity-color: #958aa8; + --chart-activity-border: #4d4658; + --chart-activity-background: #17151a; +} + .chart-more { min-height: 18px; padding-top: 3px; @@ -661,6 +701,31 @@ button { stroke-dasharray: 5 5; } +.chart-edge-group.chart-edge-trigger-always .chart-edge-line, +.chart-edge-group.chart-edge-trigger-choice .chart-edge-line, +.chart-edge-group.chart-edge-trigger-done .chart-edge-line, +.chart-edge-group.chart-edge-trigger-invoke .chart-edge-line { + stroke-dasharray: 7 5; +} + +.chart-edge-group.chart-edge-activity-process .chart-edge-line, +.chart-edge-group.chart-edge-activity-effect .chart-edge-line, +.chart-edge-group.chart-edge-activity-timer .chart-edge-line, +.chart-edge-group.chart-edge-activity-stream .chart-edge-line, +.chart-edge-group.chart-edge-activity-machine .chart-edge-line { + stroke: var(--chart-activity-color); +} + +.chart-edge-label.chart-edge-activity-process, +.chart-edge-label.chart-edge-activity-effect, +.chart-edge-label.chart-edge-activity-timer, +.chart-edge-label.chart-edge-activity-stream, +.chart-edge-label.chart-edge-activity-machine { + border-color: var(--chart-activity-border); + color: var(--chart-activity-color); + background: var(--chart-activity-background); +} + .chart-edge-group.is-incoming .chart-edge-line { stroke: #ed6a70; stroke-width: 2; @@ -827,6 +892,15 @@ button { background: rgb(78 181 131 / 14%); } +.badge-activity-process, +.badge-activity-effect, +.badge-activity-timer, +.badge-activity-stream, +.badge-activity-machine { + color: var(--chart-activity-color); + background: var(--chart-activity-background); +} + .badge-count { color: #858c96; background: transparent; diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index 465e37b..e4f94af 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -186,7 +186,7 @@ const renderActivity = (activity: VisualizationActivity, navigate: StateNavigato const title = createElement("div", "card-title") title.append(createElement("strong", undefined, activityTitle(activity))) const flags = createElement("div", "card-flags") - flags.append(badge(activity.type, "activity")) + flags.append(badge(activity.type, `activity badge-activity-${activity.type}`)) header.append(title, flags) card.append(header) diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts index 25fe9aa..e594493 100644 --- a/packages/devtools/test/internal/browser/StaticChart.test.ts +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -1,7 +1,14 @@ import { assert, describe, it } from "@effect/vitest" import * as Effect from "effect/Effect" -import { layoutChart } from "../../../src/internal/browser/chart-layout.js" -import { makeChartModel } from "../../../src/internal/browser/chart-model.js" +import { makeChartLayoutPolicy } from "../../../src/internal/browser/chart-layout-policy.js" +import { + chartEdgeTerminalClearance, + chartSelfLoopLabelGap, + ensureChartEdgeTerminalClearance, + layoutChart, + selfLoopLabelPosition +} from "../../../src/internal/browser/chart-layout.js" +import { type ChartModel, type ChartNode, makeChartModel } from "../../../src/internal/browser/chart-model.js" import { chartWheelZoom, chartZoomScrollPosition, @@ -10,10 +17,131 @@ import { minimumChartZoom } from "../../../src/internal/browser/chart-renderer.js" import { machine, snapshot } from "../../../src/internal/browser/example-machine.js" +import { invokeOutcomesMachine } from "../../../src/internal/browser/invoke-outcomes-example.js" +import { parallelCompletionMachine } from "../../../src/internal/browser/parallel-completion-example.js" import { plannerMachine } from "../../../src/internal/browser/planner-example.js" import * as MachineDocument from "../../../src/MachineDocument.js" describe("Static chart", () => { + it("keeps an orthogonal terminal segment clear for the arrowhead", () => { + assert.deepStrictEqual( + ensureChartEdgeTerminalClearance([ + { x: 100, y: 20 }, + { x: 60, y: 20 }, + { x: 60, y: 40 }, + { x: 50, y: 40 } + ]), + [ + { x: 100, y: 20 }, + { x: 74, y: 20 }, + { x: 74, y: 40 }, + { x: 50, y: 40 } + ] + ) + }) + + it("places self-transition labels outside the loop", () => { + const points = [ + { x: 20, y: 0 }, + { x: 20, y: 50 }, + { x: 80, y: 50 }, + { x: 80, y: 0 } + ] + + assert.deepStrictEqual(selfLoopLabelPosition(points, 72, 26), { + x: 50, + y: 50 + chartSelfLoopLabelGap + 13 + }) + }) + + it("derives a left-to-right policy from initial reachability", () => { + const node = (path: string, type: ChartNode["type"] = "atomic"): ChartNode => ({ + path, + label: path, + type, + parent: null, + children: [], + active: false, + initial: path === "Idle", + fields: [], + activities: [] + }) + const model: ChartModel = { + machineId: "layout-policy", + roots: ["Idle", "Detached", "Done"], + nodes: [node("Idle"), node("Detached"), node("Done", "final")], + edges: [ + { + id: "advance", + transitionId: "advance", + branchIds: ["advance"], + kind: "target", + source: "Idle", + target: "Done", + label: "Advance", + trigger: { type: "event", event: "Advance" }, + activityKind: null, + reenter: false, + acceptance: "required" + }, + { + id: "return", + transitionId: "return", + branchIds: ["return"], + kind: "target", + source: "Done", + target: "Idle", + label: "Return", + trigger: { type: "event", event: "Return" }, + activityKind: null, + reenter: false, + acceptance: "required" + }, + { + id: "refresh", + transitionId: "refresh", + branchIds: ["refresh"], + kind: "targetless", + source: "Idle", + target: null, + label: "Refresh", + trigger: { type: "event", event: "Refresh" }, + activityKind: null, + reenter: false, + acceptance: "required" + } + ], + runtimeTargets: [], + initials: [{ id: "initial:Idle", target: "Idle", parent: null }] + } + const policy = makeChartLayoutPolicy(model) + + assert.deepStrictEqual(policy.children(null).map(({ path }) => path), ["Idle", "Done", "Detached"]) + assert.deepStrictEqual(policy.node("Idle"), { + reachable: true, + rank: 0, + order: 0, + layerConstraint: null + }) + assert.strictEqual(policy.node("Done").layerConstraint, "LAST") + assert.strictEqual(policy.node("Detached").reachable, false) + assert.deepStrictEqual(policy.edge(model.edges[0]!), { + direction: "forward", + sourceSide: "EAST", + targetSide: "WEST" + }) + assert.deepStrictEqual(policy.edge(model.edges[1]!), { + direction: "backward", + sourceSide: "WEST", + targetSide: "EAST" + }) + assert.deepStrictEqual(policy.edge(model.edges[2]!), { + direction: "self", + sourceSide: "SOUTH", + targetSide: "SOUTH" + }) + }) + it("projects state fields, invocation metadata, and transition branches", () => { const document = MachineDocument.make(plannerMachine) const model = makeChartModel(document) @@ -44,6 +172,28 @@ describe("Static chart", () => { assert.deepStrictEqual(model.initials.map(({ target }) => target), ["Idle"]) }) + it("links invoke outcome edges to their declared activity type", () => { + const model = makeChartModel(MachineDocument.make(invokeOutcomesMachine)) + const activities = new Map( + model.edges.flatMap((edge) => + edge.trigger.type === "invoke" && edge.activityKind !== null + ? [[edge.trigger.id, edge.activityKind] as const] + : [] + ) + ) + + assert.deepStrictEqual( + activities, + new Map([ + ["load-document", "effect"], + ["document-updates", "stream"], + ["request-timeout", "timer"], + ["status-worker", "process"], + ["preview-worker", "machine"] + ]) + ) + }) + it("computes nested node coordinates and orthogonal transition routes", async () => { const model = makeChartModel(MachineDocument.make(machine, { snapshot })) const layout = await Effect.runPromise(layoutChart(model)) @@ -57,6 +207,15 @@ describe("Static chart", () => { assert.isAbove(application?.width ?? 0, idle?.width ?? 0) assert.strictEqual(transitionEdges.length, model.edges.length) assert.isTrue(transitionEdges.every((edge) => edge.points.length >= 2)) + assert.isTrue(transitionEdges.every((edge) => { + const bend = edge.points.at(-2)! + const end = edge.points.at(-1)! + return Math.abs(end.x - bend.x) + Math.abs(end.y - bend.y) >= chartEdgeTerminalClearance + })) + for (const initial of layout.initials) { + const target = layout.nodes.find(({ node }) => node.path === initial.initial.target) + assert.isBelow(initial.x + initial.width, target?.x ?? Number.POSITIVE_INFINITY) + } }) it("lays out targetless transitions as self-loops", async () => { @@ -74,13 +233,46 @@ describe("Static chart", () => { assert.strictEqual(laidOut.edge.source, "application.workflow.idle") assert.strictEqual(laidOut.edge.target, null) assert.isAtLeast(laidOut.points.length, 3) + const horizontalSpan = Math.max(...laidOut.points.map(({ x }) => x)) - + Math.min(...laidOut.points.map(({ x }) => x)) + const verticalSpan = Math.max(...laidOut.points.map(({ y }) => y)) - + Math.min(...laidOut.points.map(({ y }) => y)) assert.isAtLeast( - Math.max(...laidOut.points.map(({ x }) => x)) - Math.min(...laidOut.points.map(({ x }) => x)), + Math.max(horizontalSpan, verticalSpan), 30 ) + assert.isAbove( + laidOut.label.y - laidOut.labelHeight / 2, + Math.max(...laidOut.points.map(({ y }) => y)) + ) + assert.isAtMost(laidOut.label.y + laidOut.labelHeight / 2, layout.height) + const source = model.nodes.find(({ path }) => path === laidOut.edge.source) + const parent = source?.parent === null + ? undefined + : layout.nodes.find(({ node }) => node.path === source?.parent) + if (parent !== undefined) { + assert.isAtMost(laidOut.label.y + laidOut.labelHeight / 2, parent.y + parent.height) + } } }) + it("keeps a nested invoke self-loop label inside its compound parent", async () => { + const model = makeChartModel(MachineDocument.make(parallelCompletionMachine)) + const layout = await Effect.runPromise(layoutChart(model)) + const edge = layout.edges.find((candidate) => + candidate.kind === "transition" && + candidate.edge.trigger.type === "invoke" && + candidate.edge.trigger.id === "packing-sla" + ) + if (edge?.kind !== "transition") assert.fail("Expected the packing timer transition") + const source = model.nodes.find(({ path }) => path === edge.edge.source) + const parent = layout.nodes.find(({ node }) => node.path === source?.parent) + if (parent === undefined) assert.fail("Expected the packing parent state") + + assert.strictEqual(edge.edge.activityKind, "timer") + assert.isAtMost(edge.label.y + edge.labelHeight / 2, parent.y + parent.height) + }) + it("lays out runtime-resolved targets as explicit stubs", async () => { const document = MachineDocument.make(plannerMachine) const source = document.states.find(({ path }) => path === "Idle")! From a0abdd8be649127bf65be8d0efc956472209ac4b Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 26 Aug 2026 15:27:29 +0200 Subject: [PATCH 2/2] Add statechart analysis and refine routing --- .changeset/quiet-charts-route.md | 4 + .../internal/browser/chart-layout-policy.ts | 47 ++++ .../src/internal/browser/chart-layout.ts | 222 +++++++++++++----- .../src/internal/browser/chart-renderer.ts | 116 ++++++++- .../devtools/src/internal/browser/styles.css | 124 +++++++++- .../browser/transition-semantics-example.ts | 11 +- .../internal/browser/visualizer-analysis.ts | 52 ++++ .../src/internal/browser/visualizer-app.ts | 118 +++++++++- .../test/internal/browser/StaticChart.test.ts | 85 +++++++ .../browser/VisualizerAnalysis.test.ts | 67 ++++++ 10 files changed, 759 insertions(+), 87 deletions(-) create mode 100644 packages/devtools/src/internal/browser/visualizer-analysis.ts create mode 100644 packages/devtools/test/internal/browser/VisualizerAnalysis.test.ts diff --git a/.changeset/quiet-charts-route.md b/.changeset/quiet-charts-route.md index 2bbfe73..05db1fd 100644 --- a/.changeset/quiet-charts-route.md +++ b/.changeset/quiet-charts-route.md @@ -5,3 +5,7 @@ Improve the static statechart so initial and reachable states follow a stable left-to-right order, reverse and self-transitions use dedicated routes, and transition labels stay clear of arrowheads and compound boundaries. Distinguish automatic transitions with dashed lines and correlate invoke outcomes with muted colors for effect, timer, stream, process, and child-machine activities. + +Stack parallel regions into vertical lanes, separate states with no statically known path from the initial state, and make long or intersecting routes easier to follow with rounded corners, edge casing, and direction cues. + +Add a machine analysis inspector that reports declared public events without handlers and state subtrees without a statically known path from the initial configuration. diff --git a/packages/devtools/src/internal/browser/chart-layout-policy.ts b/packages/devtools/src/internal/browser/chart-layout-policy.ts index fe847fa..7b22202 100644 --- a/packages/devtools/src/internal/browser/chart-layout-policy.ts +++ b/packages/devtools/src/internal/browser/chart-layout-policy.ts @@ -4,6 +4,7 @@ export type ChartPortSide = "NORTH" | "EAST" | "SOUTH" | "WEST" export interface ChartNodeLayoutPolicy { readonly reachable: boolean + readonly staticPath: boolean readonly rank: number | null readonly order: number readonly layerConstraint: "LAST" | null @@ -23,6 +24,7 @@ export interface ChartLayoutPolicy { const unreachableNode: ChartNodeLayoutPolicy = { reachable: false, + staticPath: false, rank: null, order: Number.MAX_SAFE_INTEGER, layerConstraint: null @@ -63,6 +65,50 @@ export const makeChartLayoutPolicy = (model: ChartModel): ChartLayoutPolicy => { const nodePolicies = new Map() const orderedChildren = new Map>() + const initialsByParent = new Map>() + for (const initial of model.initials) { + const initials = initialsByParent.get(initial.parent) ?? [] + initials.push(initial.target) + initialsByParent.set(initial.parent, initials) + } + const outgoing = new Map>() + for (const edge of model.edges) { + if (edge.kind !== "target" || edge.target === null) continue + const targets = outgoing.get(edge.source) ?? [] + targets.push(edge.target) + outgoing.set(edge.source, targets) + } + + const staticPaths = new Set() + const entered = new Set() + const queue: Array = [] + const markReachable = (path: string): void => { + let current = nodes.get(path) + while (current !== undefined) { + if (!staticPaths.has(current.path)) { + staticPaths.add(current.path) + queue.push(current.path) + } + current = current.parent === null ? undefined : nodes.get(current.parent) + } + } + const enter = (path: string): void => { + const node = nodes.get(path) + if (node === undefined) return + markReachable(path) + if (entered.has(path)) return + entered.add(path) + if (node.type === "parallel") { + for (const child of node.children) enter(child) + } else if (node.type === "compound") { + for (const initial of initialsByParent.get(node.path) ?? []) enter(initial) + } + } + for (const initial of initialsByParent.get(null) ?? []) enter(initial) + for (let index = 0; index < queue.length; index++) { + for (const target of outgoing.get(queue[index]!) ?? []) enter(target) + } + for (const [parent, children] of childrenByParent) { const childPaths = new Set(children.map(({ path }) => path)) const adjacency = new Map(children.map(({ path }) => [path, new Set()])) @@ -110,6 +156,7 @@ export const makeChartLayoutPolicy = (model: ChartModel): ChartLayoutPolicy => { const rank = ranks.get(node.path) ?? null nodePolicies.set(node.path, { reachable: rank !== null, + staticPath: staticPaths.has(node.path), rank, order, layerConstraint: node.type === "final" ? "LAST" : null diff --git a/packages/devtools/src/internal/browser/chart-layout.ts b/packages/devtools/src/internal/browser/chart-layout.ts index 8328b54..b0f1705 100644 --- a/packages/devtools/src/internal/browser/chart-layout.ts +++ b/packages/devtools/src/internal/browser/chart-layout.ts @@ -45,6 +45,16 @@ export interface LaidOutChartRuntimeTarget { readonly height: number } +export interface LaidOutChartRegion { + readonly kind: "unconnected" + readonly parent: string | null + readonly nodePaths: ReadonlyArray + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + export interface LaidOutChartTransition { readonly kind: "transition" readonly edge: ChartEdge @@ -63,6 +73,7 @@ export interface LaidOutChartInitialEdge { export interface LaidOutChart { readonly width: number readonly height: number + readonly regions: ReadonlyArray readonly nodes: ReadonlyArray readonly initials: ReadonlyArray readonly runtimeTargets: ReadonlyArray @@ -109,8 +120,31 @@ const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}` const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target` const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}` const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target` +const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}` const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source +interface UnconnectedRegion { + readonly id: string + readonly parent: string | null + readonly nodePaths: ReadonlyArray +} + +const unconnectedRegions = ( + model: ChartModel, + policy: ReturnType +): ReadonlyArray => { + const parents = new Set([null, ...model.nodes.map(({ parent }) => parent)]) + return [...parents].flatMap((parent): ReadonlyArray => { + if (parent !== null && !policy.node(parent).staticPath) return [] + const nodePaths = policy.children(parent) + .filter(({ path }) => !policy.node(path).staticPath) + .map(({ path }) => path) + return nodePaths.length === 0 + ? [] + : [{ id: unconnectedRegionId(parent), parent, nodePaths }] + }) +} + const portsByState = ( model: ChartModel, edgePolicy: ReturnType["edge"] @@ -147,9 +181,14 @@ const labelMetric = (label: string): { readonly width: number; readonly height: height: 26 }) -const makeGraph = (model: ChartModel): ElkNode => { - const policy = makeChartLayoutPolicy(model) +const makeGraph = ( + model: ChartModel, + policy: ReturnType, + regions: ReadonlyArray +): ElkNode => { const nodesByPath = new Map(model.nodes.map((node) => [node.path, node])) + const regionsByParent = new Map(regions.map((region) => [region.parent, region])) + const sourceByEdgeId = new Map(model.edges.map((edge) => [edge.id, edge.source])) const selfLoopParents = new Set( model.edges.flatMap((edge) => { if (!isSelfTransition(edge)) return [] @@ -171,66 +210,102 @@ const makeGraph = (model: ChartModel): ElkNode => { } const ports = portsByState(model, policy.edge) - const children = (parent: string | null): Array => [ - ...(initialsByParent.get(parent) ?? []).map((initial): ElkNode => ({ - id: initialNodeId(initial), - width: 14, - height: 14, + const initialNode = (initial: ChartInitial): ElkNode => ({ + id: initialNodeId(initial), + width: 14, + height: 14, + layoutOptions: { + "elk.layered.layering.layerConstraint": "FIRST" + } + }) + const runtimeNode = (target: ChartRuntimeTarget): ElkNode => ({ + id: runtimeNodeId(target), + width: 118, + height: 34, + ports: [{ + id: runtimeTargetPortId(target), + width: 6, + height: 6, + layoutOptions: { "elk.port.side": "WEST" } + }], + layoutOptions: { "elk.portConstraints": "FIXED_SIDE" } + }) + + const stateNode = (node: ChartNode, suppressUnconnectedRegion: boolean): ElkNode => { + const metric = nodeMetric(node) + const nodePolicy = policy.node(node.path) + const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath) + const bottomPadding = 28 + (selfLoopParents.has(node.path) ? chartSelfLoopParentAllowance : 0) + const common = { + id: node.path, + ports: [...ports.get(node.path) ?? []], layoutOptions: { - "elk.layered.layering.layerConstraint": "FIRST" - } - })), - ...policy.children(parent).map((node): ElkNode => { - const metric = nodeMetric(node) - const descendants = children(node.path) - const nodePolicy = policy.node(node.path) - const bottomPadding = 28 + (selfLoopParents.has(node.path) ? chartSelfLoopParentAllowance : 0) - const common = { - id: node.path, - ports: [...ports.get(node.path) ?? []], - layoutOptions: { - "elk.portConstraints": "FIXED_SIDE", - "elk.spacing.portPort": "22", - ...(nodePolicy.layerConstraint === null - ? {} - : { "elk.layered.layering.layerConstraint": nodePolicy.layerConstraint }) - } - } - if (descendants.length === 0) { - return { - ...common, - width: metric.width, - height: metric.height - } + "elk.portConstraints": "FIXED_SIDE", + "elk.spacing.portPort": "22", + ...(nodePolicy.layerConstraint === null + ? {} + : { "elk.layered.layering.layerConstraint": nodePolicy.layerConstraint }) } + } + if (descendants.length === 0) { return { ...common, - children: descendants, - layoutOptions: { - ...common.layoutOptions, - "elk.algorithm": "layered", - "elk.direction": "RIGHT", - "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=${bottomPadding},right=28]`, - "elk.nodeSize.constraints": "MINIMUM_SIZE", - "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`, - "elk.spacing.nodeNode": "44", - "elk.layered.spacing.nodeNodeBetweenLayers": "108" - } + width: metric.width, + height: metric.height } - }), - ...(runtimeTargetsByParent.get(parent) ?? []).map((target): ElkNode => ({ - id: runtimeNodeId(target), - width: 118, - height: 34, - ports: [{ - id: runtimeTargetPortId(target), - width: 6, - height: 6, - layoutOptions: { "elk.port.side": "WEST" } - }], - layoutOptions: { "elk.portConstraints": "FIXED_SIDE" } - })) - ] + } + return { + ...common, + children: descendants, + layoutOptions: { + ...common.layoutOptions, + "elk.algorithm": "layered", + "elk.direction": node.type === "parallel" ? "DOWN" : "RIGHT", + "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=${bottomPadding},right=28]`, + "elk.nodeSize.constraints": "MINIMUM_SIZE", + "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`, + "elk.spacing.nodeNode": "44", + "elk.layered.spacing.nodeNodeBetweenLayers": node.type === "parallel" ? "64" : "108" + } + } + } + + function children(parent: string | null, suppressUnconnectedRegion = false): Array { + const region = suppressUnconnectedRegion ? undefined : regionsByParent.get(parent) + const regionPaths = new Set(region?.nodePaths ?? []) + const initials = initialsByParent.get(parent) ?? [] + const runtimeTargets = runtimeTargetsByParent.get(parent) ?? [] + const states = policy.children(parent) + const regular: Array = [ + ...initials.filter(({ target }) => !regionPaths.has(target)).map(initialNode), + ...states.filter(({ path }) => !regionPaths.has(path)).map((node) => stateNode(node, suppressUnconnectedRegion)), + ...runtimeTargets + .filter(({ edgeId }) => !regionPaths.has(sourceByEdgeId.get(edgeId) ?? "")) + .map(runtimeNode) + ] + if (region === undefined) return regular + + const regionChildren: Array = [ + ...initials.filter(({ target }) => regionPaths.has(target)).map(initialNode), + ...states.filter(({ path }) => regionPaths.has(path)).map((node) => stateNode(node, true)), + ...runtimeTargets + .filter(({ edgeId }) => regionPaths.has(sourceByEdgeId.get(edgeId) ?? "")) + .map(runtimeNode) + ] + regular.push({ + id: region.id, + children: regionChildren, + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.padding": "[top=54,left=24,bottom=24,right=24]", + "elk.layered.layering.layerConstraint": "LAST", + "elk.spacing.nodeNode": "52", + "elk.layered.spacing.nodeNodeBetweenLayers": "128" + } + }) + return regular + } return { id: "chart-root", @@ -468,11 +543,17 @@ export const ensureChartEdgeTerminalClearance = ( return compactPoints(result) } -const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => { +const collectLayout = ( + model: ChartModel, + graph: ElkNode, + unconnected: ReadonlyArray +): LaidOutChart => { const chartNodes = new Map(model.nodes.map((node) => [node.path, node])) const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial])) const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target])) + const chartRegions = new Map(unconnected.map((region) => [region.id, region])) const offsets = new Map([[graph.id, { x: 0, y: 0 }]]) + const regions: Array = [] const nodes: Array = [] const initials: Array = [] const runtimeTargets: Array = [] @@ -480,6 +561,18 @@ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => { const visit = (node: ElkNode, parentOffset: ChartPoint): void => { const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 }) offsets.set(node.id, absolute) + const chartRegion = chartRegions.get(node.id) + if (chartRegion !== undefined) { + regions.push({ + kind: "unconnected", + parent: chartRegion.parent, + nodePaths: chartRegion.nodePaths, + x: absolute.x, + y: absolute.y, + width: node.width ?? 0, + height: node.height ?? 0 + }) + } const chartNode = chartNodes.get(node.id) if (chartNode !== undefined) { const metric = nodeMetric(chartNode) @@ -574,6 +667,7 @@ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => { return { width: Math.max(360, graph.width ?? 0, contentWidth + 20), height: Math.max(280, graph.height ?? 0, contentHeight + 20), + regions, nodes, initials, runtimeTargets, @@ -582,7 +676,11 @@ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => { } export const layoutChart = (model: ChartModel): Effect.Effect => - Effect.tryPromise({ - try: () => elk.layout(makeGraph(model)), - catch: (cause) => new ChartLayoutError({ cause }) - }).pipe(Effect.map((graph) => collectLayout(model, graph))) + Effect.suspend(() => { + const policy = makeChartLayoutPolicy(model) + const regions = unconnectedRegions(model, policy) + return Effect.tryPromise({ + try: () => elk.layout(makeGraph(model, policy, regions)), + catch: (cause) => new ChartLayoutError({ cause }) + }).pipe(Effect.map((graph) => collectLayout(model, graph, regions))) + }) diff --git a/packages/devtools/src/internal/browser/chart-renderer.ts b/packages/devtools/src/internal/browser/chart-renderer.ts index 81c66e1..445883b 100644 --- a/packages/devtools/src/internal/browser/chart-renderer.ts +++ b/packages/devtools/src/internal/browser/chart-renderer.ts @@ -184,10 +184,83 @@ const stateContent = (layout: LaidOutChartNode, stateStatus: HTMLElement): Docum return fragment } -const pathData = (points: ReadonlyArray): string => { +const pointAlong = (start: ChartPoint, end: ChartPoint, distance: number): ChartPoint => { + const length = Math.abs(end.x - start.x) + Math.abs(end.y - start.y) + if (length === 0) return start + const ratio = distance / length + return { + x: start.x + (end.x - start.x) * ratio, + y: start.y + (end.y - start.y) * ratio + } +} + +export const chartEdgePathData = (points: ReadonlyArray, radius = 9): string => { const first = points[0] if (first === undefined) return "" - return points.slice(1).reduce((path, point) => `${path} L ${point.x} ${point.y}`, `M ${first.x} ${first.y}`) + if (points.length === 1) return `M ${first.x} ${first.y}` + let path = `M ${first.x} ${first.y}` + for (let index = 1; index < points.length - 1; index++) { + const previous = points[index - 1]! + const corner = points[index]! + const next = points[index + 1]! + const incoming = Math.abs(corner.x - previous.x) + Math.abs(corner.y - previous.y) + const outgoing = Math.abs(next.x - corner.x) + Math.abs(next.y - corner.y) + const cornerRadius = Math.min(radius, incoming / 2, outgoing / 2) + const before = pointAlong(corner, previous, cornerRadius) + const after = pointAlong(corner, next, cornerRadius) + path += ` L ${before.x} ${before.y} Q ${corner.x} ${corner.y} ${after.x} ${after.y}` + } + const last = points.at(-1)! + return `${path} L ${last.x} ${last.y}` +} + +export interface ChartDirectionCue { + readonly start: ChartPoint + readonly end: ChartPoint +} + +export const chartDirectionCue = ( + points: ReadonlyArray, + minimumRouteLength = 280 +): ChartDirectionCue | null => { + const segments = points.slice(1).map((end, index) => { + const start = points[index]! + return { + start, + end, + length: Math.abs(end.x - start.x) + Math.abs(end.y - start.y) + } + }) + const total = segments.reduce((sum, segment) => sum + segment.length, 0) + if (total < minimumRouteLength) return null + let remaining = total * 0.32 + let selected: { readonly start: ChartPoint; readonly end: ChartPoint; readonly distance: number } | undefined + for (const segment of segments) { + if (segment.length >= 24 && remaining <= segment.length) { + selected = { + start: segment.start, + end: segment.end, + distance: Math.min(segment.length - 12, Math.max(12, remaining)) + } + break + } + remaining -= segment.length + } + if (selected === undefined) { + const segment = [...segments].sort((left, right) => right.length - left.length)[0] + if (segment === undefined || segment.length < 24) return null + selected = { start: segment.start, end: segment.end, distance: segment.length / 2 } + } + const center = pointAlong(selected.start, selected.end, selected.distance) + const deltaX = selected.end.x - selected.start.x + const deltaY = selected.end.y - selected.start.y + const horizontal = Math.abs(deltaX) >= Math.abs(deltaY) + const directionX = horizontal ? Math.sign(deltaX) : 0 + const directionY = horizontal ? 0 : Math.sign(deltaY) + return { + start: { x: center.x - directionX * 5, y: center.y - directionY * 5 }, + end: { x: center.x + directionX * 5, y: center.y + directionY * 5 } + } } const setStateClass = ( @@ -229,7 +302,19 @@ const render = ( const arrow = svgElement("path") arrow.setAttribute("d", "M 1 1 L 11 6 L 1 11 z") marker.append(arrow) - definitions.append(marker) + const directionMarker = svgElement("marker") + directionMarker.id = "chart-direction" + directionMarker.setAttribute("viewBox", "0 0 8 8") + directionMarker.setAttribute("refX", "7") + directionMarker.setAttribute("refY", "4") + directionMarker.setAttribute("markerWidth", "8") + directionMarker.setAttribute("markerHeight", "8") + directionMarker.setAttribute("markerUnits", "userSpaceOnUse") + directionMarker.setAttribute("orient", "auto") + const directionArrow = svgElement("path") + directionArrow.setAttribute("d", "M 1 1 L 7 4 L 1 7 z") + directionMarker.append(directionArrow) + definitions.append(marker, directionMarker) svg.append(definitions) const nodesLayer = element("div", "chart-nodes") const labelsLayer = element("div", "chart-labels") @@ -275,6 +360,15 @@ const render = ( edgeElements.set(id, registered) } + for (const regionLayout of layout.regions) { + const region = element("div", "chart-unconnected-region") + region.title = "No statically known transition reaches these states. Runtime-resolved targets may still reach them." + position(region, regionLayout) + const label = element("div", "chart-unconnected-region-label", "No static path from initial") + region.append(label) + regions.append(region) + } + for (const laidOut of layout.nodes) { if (laidOut.node.children.length > 0) { const region = element("div", `chart-compound chart-compound-${laidOut.node.type}`) @@ -345,13 +439,25 @@ const render = ( : "" }` ) + const casing = svgElement("path", "chart-edge-casing") const visible = svgElement("path", "chart-edge-line") - const route = pathData(laidOut.points) + const route = chartEdgePathData(laidOut.points) + casing.setAttribute("d", route) visible.setAttribute("d", route) visible.setAttribute("marker-end", "url(#chart-arrow)") const hit = svgElement("path", "chart-edge-hit") hit.setAttribute("d", route) - group.append(visible, hit) + group.append(casing, visible) + if (laidOut.kind === "transition") { + const cue = chartDirectionCue(laidOut.points) + if (cue !== null) { + const direction = svgElement("path", "chart-edge-direction") + direction.setAttribute("d", `M ${cue.start.x} ${cue.start.y} L ${cue.end.x} ${cue.end.y}`) + direction.setAttribute("marker-end", "url(#chart-direction)") + group.append(direction) + } + } + group.append(hit) svg.append(group) if (laidOut.kind === "initial") continue diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css index 53a87d2..9c3d046 100644 --- a/packages/devtools/src/internal/browser/styles.css +++ b/packages/devtools/src/internal/browser/styles.css @@ -308,6 +308,31 @@ button { cursor: default; } +.toolbar-button[aria-pressed="true"] { + color: #dce8ff; + background: rgb(65 103 165 / 28%); +} + +.analysis-button { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.analysis-button[hidden] { + display: none; +} + +.analysis-button-count { + min-width: 17px; + padding: 2px 5px; + border-radius: 8px; + color: #9da6b3; + background: #20242a; + font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-align: center; +} + .topology-chart { display: flex; flex: 1; @@ -411,6 +436,23 @@ button { background: rgb(20 24 30 / 74%); } +.chart-unconnected-region { + position: absolute; + border: 1px dashed #353b44; + border-radius: 5px; + background: rgb(14 16 19 / 58%); +} + +.chart-unconnected-region-label { + position: absolute; + top: 17px; + left: 24px; + color: #727984; + font: 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.05em; + text-transform: uppercase; +} + .chart-state { position: absolute; display: block; @@ -631,17 +673,30 @@ button { user-select: none; } +.chart-edge-casing, .chart-edge-line, +.chart-edge-direction, .chart-edge-hit { fill: none; } -.chart-edge-line { +.chart-edge-casing { + stroke: #0b0c0e; + stroke-width: 5.5; + vector-effect: non-scaling-stroke; +} + +.chart-edge-line, +.chart-edge-direction { stroke: #626b77; stroke-width: 1.4; vector-effect: non-scaling-stroke; } +.chart-edge-direction { + pointer-events: none; +} + .chart-edge-initial .chart-edge-line { stroke: #89929e; } @@ -653,7 +708,8 @@ button { pointer-events: stroke; } -#chart-arrow path { +#chart-arrow path, +#chart-direction path { fill: context-stroke; } @@ -687,7 +743,8 @@ button { color: #d4b4e9; } -.chart-edge-group.is-hovered .chart-edge-line { +.chart-edge-group.is-hovered .chart-edge-line, +.chart-edge-group.is-hovered .chart-edge-direction { stroke: #7c9ed2; stroke-width: 1.8; } @@ -712,7 +769,12 @@ button { .chart-edge-group.chart-edge-activity-effect .chart-edge-line, .chart-edge-group.chart-edge-activity-timer .chart-edge-line, .chart-edge-group.chart-edge-activity-stream .chart-edge-line, -.chart-edge-group.chart-edge-activity-machine .chart-edge-line { +.chart-edge-group.chart-edge-activity-machine .chart-edge-line, +.chart-edge-group.chart-edge-activity-process .chart-edge-direction, +.chart-edge-group.chart-edge-activity-effect .chart-edge-direction, +.chart-edge-group.chart-edge-activity-timer .chart-edge-direction, +.chart-edge-group.chart-edge-activity-stream .chart-edge-direction, +.chart-edge-group.chart-edge-activity-machine .chart-edge-direction { stroke: var(--chart-activity-color); } @@ -726,22 +788,26 @@ button { background: var(--chart-activity-background); } -.chart-edge-group.is-incoming .chart-edge-line { +.chart-edge-group.is-incoming .chart-edge-line, +.chart-edge-group.is-incoming .chart-edge-direction { stroke: #ed6a70; stroke-width: 2; } -.chart-edge-group.is-outgoing .chart-edge-line { +.chart-edge-group.is-outgoing .chart-edge-line, +.chart-edge-group.is-outgoing .chart-edge-direction { stroke: #6eb6dc; stroke-width: 2; } -.chart-edge-group.is-selected .chart-edge-line { +.chart-edge-group.is-selected .chart-edge-line, +.chart-edge-group.is-selected .chart-edge-direction { stroke: #8ab5ff; stroke-width: 2.5; } -.chart-edge-group.is-walkthrough-available .chart-edge-line { +.chart-edge-group.is-walkthrough-available .chart-edge-line, +.chart-edge-group.is-walkthrough-available .chart-edge-direction { stroke: #d5aa57; stroke-width: 2; } @@ -909,7 +975,7 @@ button { .inspector { position: absolute; z-index: 10; - top: 14px; + top: 60px; right: 14px; bottom: 14px; width: min(460px, calc(100% - 28px)); @@ -1047,6 +1113,13 @@ button { border-bottom: 1px solid var(--line); } +.analysis-summary { + margin: 12px 0 0; + color: #7f8690; + font-size: 12px; + line-height: 1.6; +} + .state-annotations { margin-top: 18px; } @@ -1145,6 +1218,33 @@ button { background: var(--surface-raised); } +.analysis-card { + padding-bottom: 12px; +} + +.analysis-card .state-link { + color: #e2e4e7; + font-size: 12px; + font-weight: 600; +} + +.analysis-message, +.analysis-qualification { + margin: 0; + padding: 0 12px; + font-size: 11px; + line-height: 1.6; +} + +.analysis-message { + color: #b9bec6; +} + +.analysis-qualification { + margin-top: 5px; + color: #747c87; +} + .card-header { display: flex; min-height: 42px; @@ -1620,7 +1720,7 @@ button { } .inspector { - top: 8px; + top: 54px; right: 8px; bottom: 8px; width: min(440px, calc(100% - 16px)); @@ -1646,6 +1746,10 @@ button { padding: 48px 14px 18px; } + .inspector { + top: 84px; + } + .zoom-controls { bottom: 8px; left: 8px; diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts index 6378c5c..18cb4a3 100644 --- a/packages/devtools/src/internal/browser/transition-semantics-example.ts +++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts @@ -33,6 +33,9 @@ class Paused extends Schema.TaggedClass("TransitionPaused")("Paused", { class Published extends Schema.TaggedClass("TransitionPublished")("Published", { result: Schema.String }) {} +class Disabled extends Schema.TaggedClass("TransitionDisabled")("Disabled", { + reason: Schema.String +}) {} class Create extends Schema.TaggedClass("TransitionCreate")("Create", { text: Schema.String, @@ -67,6 +70,9 @@ class BumpWorkspace extends Schema.TaggedClass("TransitionBumpWor "BumpWorkspace", {} ) {} +class Archive extends Schema.TaggedClass("TransitionArchive")("Archive", { + reason: Schema.String +}) {} const TransitionStates = Machine.states({ Workspace: { @@ -91,6 +97,7 @@ const TransitionStates = Machine.states({ } }, Paused, + Disabled, Published: { schema: Published, type: "final", output: Schema.String } }) @@ -121,7 +128,8 @@ export const transitionSemanticsMachine = Machine.make({ Refresh, Ignore, MaybeHandle, - BumpWorkspace + BumpWorkspace, + Archive ), initial: (to) => to.Paused().resolve(({ target }) => target.decoded(new Paused({ reason: "not started" }))) }).handle({ @@ -230,6 +238,7 @@ export const transitionSemanticsMachine = Machine.make({ ) } }, + Disabled: {}, Published: { output: ({ state }) => state.result } diff --git a/packages/devtools/src/internal/browser/visualizer-analysis.ts b/packages/devtools/src/internal/browser/visualizer-analysis.ts new file mode 100644 index 0000000..f74ac2d --- /dev/null +++ b/packages/devtools/src/internal/browser/visualizer-analysis.ts @@ -0,0 +1,52 @@ +import type { MachineDocument as VisualizationDocument } from "../../MachineDocument.js" +import { makeChartLayoutPolicy } from "./chart-layout-policy.js" +import { makeChartModel } from "./chart-model.js" + +export interface UnhandledPublicEventWarning { + readonly _tag: "UnhandledPublicEvent" + readonly event: string +} + +export interface NoStaticPathNote { + readonly _tag: "NoStaticPathFromInitial" + readonly path: string + readonly label: string + readonly type: "atomic" | "compound" | "parallel" | "final" | "history" | "choice" + readonly descendantCount: number +} + +export interface VisualizerAnalysis { + readonly warnings: ReadonlyArray + readonly topologyNotes: ReadonlyArray +} + +/** Static findings derived without executing machine callbacks. */ +export const analyzeVisualization = (document: VisualizationDocument): VisualizerAnalysis => { + const handledEvents = new Set( + document.transitions.flatMap((transition): ReadonlyArray => + transition.trigger.type === "event" ? [transition.trigger.event] : [] + ) + ) + const declaredEvents = new Set(document.inputs.events.map(({ event }) => event)) + const warnings = [...declaredEvents].flatMap((event): ReadonlyArray => + handledEvents.has(event) ? [] : [{ _tag: "UnhandledPublicEvent", event }] + ) + + const chart = makeChartModel(document) + const policy = makeChartLayoutPolicy(chart) + const topologyNotes = chart.nodes.flatMap((node): ReadonlyArray => { + const hasStaticPath = policy.node(node.path).staticPath + const parentHasStaticPath = node.parent === null || policy.node(node.parent).staticPath + if (hasStaticPath || !parentHasStaticPath) return [] + const descendantCount = chart.nodes.filter(({ path }) => path.startsWith(`${node.path}.`)).length + return [{ + _tag: "NoStaticPathFromInitial", + path: node.path, + label: node.label, + type: node.type, + descendantCount + }] + }) + + return { warnings, topologyNotes } +} diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index e4f94af..01c9787 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -19,6 +19,7 @@ import { renderChart } from "./chart-renderer.js" import { type InputField, projectInputSchema } from "./input-form.js" +import { analyzeVisualization, type VisualizerAnalysis } from "./visualizer-analysis.js" import { branchTargetApi, type IncomingTransition, @@ -394,6 +395,8 @@ export const renderVisualizer = ( diagnostics: ReadonlyArray = [] ): void => { const model = makeVisualizerModel(visualization) + const analysis = analyzeVisualization(visualization) + const analysisCount = analysis.warnings.length + analysis.topologyNotes.length const transitionsById = new Map(visualization.transitions.map((transition) => [transition.id, transition])) const eventSchemas = new Map(visualization.inputs.events.map(({ event, schema }) => [event, schema])) const relatedFrom = new Set() @@ -405,6 +408,7 @@ export const renderVisualizer = ( let selectedFrame: MachineWalkthrough.Frame | undefined let walkthrough: MachineWalkthrough.Session | undefined let chartView: ChartView | undefined + let inspectorView: "selection" | "analysis" | undefined const activePaths = (): ReadonlyArray => walkthrough === undefined ? model.activePaths : MachineWalkthrough.current(walkthrough).after.activePaths @@ -417,7 +421,7 @@ export const renderVisualizer = ( chartPanel.setAttribute("aria-label", `${model.machineId} topology`) const inspector = createElement("aside", "inspector") inspector.setAttribute("aria-live", "polite") - inspector.setAttribute("aria-label", "Selection details") + inspector.setAttribute("aria-label", "Machine inspector") inspector.hidden = true const inspectorClose = createElement("button", "inspector-close", "Close") inspectorClose.type = "button" @@ -448,16 +452,28 @@ export const renderVisualizer = ( const walkthroughButton = createElement("button", "toolbar-button", "Start simulation") walkthroughButton.type = "button" walkthroughButton.disabled = model.roots.length === 0 + const analysisButton = createElement("button", "toolbar-button analysis-button") + analysisButton.type = "button" + analysisButton.hidden = analysisCount === 0 + analysisButton.setAttribute("aria-pressed", "false") + analysisButton.append( + createElement("span", undefined, "Analysis"), + createElement("span", "analysis-button-count", String(analysisCount)) + ) const hideInspector = (): void => { inspectorContent.replaceChildren() inspector.hidden = true + inspectorView = undefined detailsButton.textContent = "View details" + analysisButton.setAttribute("aria-pressed", "false") } - const showInspector = (): void => { + const showInspector = (view: "selection" | "analysis"): void => { inspector.hidden = false - detailsButton.textContent = "Hide details" + inspectorView = view + detailsButton.textContent = view === "selection" ? "Hide details" : "View details" + analysisButton.setAttribute("aria-pressed", view === "analysis" ? "true" : "false") } const closeChoicePicker = (): void => { @@ -467,7 +483,7 @@ export const renderVisualizer = ( const renderInspection = (inspection: StateInspection): void => { inspectorContent.replaceChildren() - showInspector() + showInspector("selection") const header = createElement("header", "inspector-header") const breadcrumbs = createElement("nav", "breadcrumbs") breadcrumbs.setAttribute("aria-label", "State path") @@ -528,7 +544,7 @@ export const renderVisualizer = ( const renderTransitionInspection = (transition: VisualizationTransition): void => { inspectorContent.replaceChildren() - showInspector() + showInspector("selection") const header = createElement("header", "inspector-header") const titleRow = createElement("div", "inspector-title-row") const flags = createElement("div", "card-flags") @@ -560,6 +576,82 @@ export const renderVisualizer = ( } } + const renderAnalysis = (findings: VisualizerAnalysis): void => { + inspectorContent.replaceChildren() + showInspector("analysis") + + const header = createElement("header", "inspector-header analysis-header") + const titleRow = createElement("div", "inspector-title-row") + titleRow.append( + createElement("h2", undefined, "Analysis"), + badge(String(findings.warnings.length + findings.topologyNotes.length), "count") + ) + header.append( + titleRow, + createElement( + "p", + "analysis-summary", + "Static checks from the captured machine definition. Machine callbacks are not executed." + ) + ) + inspectorContent.append(header) + + const warnings = createElement("section", "inspector-section analysis-section") + warnings.append(inspectionSection("Unhandled events", findings.warnings.length)) + if (findings.warnings.length === 0) { + warnings.append(createElement("p", "section-empty", "Every declared public event has a registered handler.")) + } else { + for (const warning of findings.warnings) { + const card = createElement("article", "inspection-card analysis-card") + const cardHeader = createElement("div", "card-header") + const title = createElement("div", "card-title") + title.append(createElement("strong", undefined, warning.event)) + cardHeader.append(title, badge("no handler", "warning")) + card.append( + cardHeader, + createElement( + "p", + "analysis-message", + "This public event is declared, but no state registers a handler for it." + ) + ) + warnings.append(card) + } + } + inspectorContent.append(warnings) + + const topology = createElement("section", "inspector-section analysis-section") + topology.append(inspectionSection("Topology notes", findings.topologyNotes.length)) + if (findings.topologyNotes.length === 0) { + topology.append(createElement("p", "section-empty", "Every state appears on a static path from initial.")) + } else { + for (const note of findings.topologyNotes) { + const card = createElement("article", "inspection-card analysis-card") + const cardHeader = createElement("div", "card-header") + const title = createElement("div", "card-title") + title.append(stateLink(note.path, note.label, navigateToState)) + cardHeader.append(title, badge(note.type, "state")) + card.append( + cardHeader, + createElement( + "p", + "analysis-message", + note.descendantCount === 0 + ? "No statically known path reaches this state from the machine's initial configuration." + : `No statically known path reaches this state or its ${note.descendantCount} descendants from the machine's initial configuration.` + ), + createElement( + "p", + "analysis-qualification", + "Runtime-resolved targets or a resumed snapshot may still reach it." + ) + ) + topology.append(card) + } + } + inspectorContent.append(topology) + } + const renderPathGroup = (label: string, paths: ReadonlyArray): HTMLElement => { const group = createElement("div", "trace-path-group") group.append(createElement("span", "trace-label", label)) @@ -572,7 +664,7 @@ export const renderVisualizer = ( const renderWalkthroughTrace = (frame: MachineWalkthrough.Frame): void => { inspectorContent.replaceChildren() - showInspector() + showInspector("selection") const header = createElement("header", "inspector-header trace-header") const eyebrow = createElement("div", "inspector-eyebrow") eyebrow.append(badge("walkthrough", "active")) @@ -916,7 +1008,7 @@ export const renderVisualizer = ( inspectorClose.addEventListener("click", hideInspector) choicePickerClose.addEventListener("click", closeChoicePicker) detailsButton.addEventListener("click", () => { - if (!inspector.hidden) { + if (!inspector.hidden && inspectorView === "selection") { hideInspector() } else if (selectedFrame !== undefined) { renderWalkthroughTrace(selectedFrame) @@ -928,6 +1020,13 @@ export const renderVisualizer = ( if (transition !== undefined) renderTransitionInspection(transition) } }) + analysisButton.addEventListener("click", () => { + if (!inspector.hidden && inspectorView === "analysis") { + hideInspector() + } else { + renderAnalysis(analysis) + } + }) revealActiveButton.addEventListener("click", () => chartView?.revealStates(activePaths())) const zoomOutButton = createElement("button", "toolbar-button zoom-button", "−") @@ -971,7 +1070,7 @@ export const renderVisualizer = ( const runtimeText = createElement("span") runtime.append(runtimeDot, runtimeText) const toolbarActions = createElement("div", "toolbar-actions") - toolbarActions.append(clearButton, detailsButton, walkthroughButton, revealActiveButton) + toolbarActions.append(clearButton, detailsButton, walkthroughButton, revealActiveButton, analysisButton) const zoomControls = createElement("div", "zoom-controls") zoomControls.setAttribute("role", "group") zoomControls.setAttribute("aria-label", "Chart zoom") @@ -1002,10 +1101,11 @@ export const renderVisualizer = ( revealActiveButton.disabled = active.size === 0 clearButton.hidden = simulating detailsButton.hidden = simulating + analysisButton.hidden = simulating || analysisCount === 0 chartPanel.classList.toggle("is-simulating", simulating) if (simulating) hideInspector() renderWalkthroughDock() - if (!simulating && !inspector.hidden) { + if (!simulating && !inspector.hidden && inspectorView === "selection") { if (selectedFrame !== undefined) { renderWalkthroughTrace(selectedFrame) } else if (selectedPath !== undefined) { diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts index e594493..80f93e8 100644 --- a/packages/devtools/test/internal/browser/StaticChart.test.ts +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -10,6 +10,8 @@ import { } from "../../../src/internal/browser/chart-layout.js" import { type ChartModel, type ChartNode, makeChartModel } from "../../../src/internal/browser/chart-model.js" import { + chartDirectionCue, + chartEdgePathData, chartWheelZoom, chartZoomScrollPosition, isChartPan, @@ -20,9 +22,33 @@ import { machine, snapshot } from "../../../src/internal/browser/example-machine import { invokeOutcomesMachine } from "../../../src/internal/browser/invoke-outcomes-example.js" import { parallelCompletionMachine } from "../../../src/internal/browser/parallel-completion-example.js" import { plannerMachine } from "../../../src/internal/browser/planner-example.js" +import { transitionSemanticsMachine } from "../../../src/internal/browser/transition-semantics-example.js" import * as MachineDocument from "../../../src/MachineDocument.js" describe("Static chart", () => { + it("rounds orthogonal bends and places direction cues on long routes", () => { + assert.strictEqual( + chartEdgePathData([ + { x: 0, y: 0 }, + { x: 40, y: 0 }, + { x: 40, y: 30 }, + { x: 100, y: 30 } + ], 10), + "M 0 0 L 30 0 Q 40 0 40 10 L 40 20 Q 40 30 50 30 L 100 30" + ) + assert.deepStrictEqual( + chartDirectionCue([{ x: 0, y: 0 }, { x: 320, y: 0 }]), + { start: { x: 97.4, y: 0 }, end: { x: 107.4, y: 0 } } + ) + const nearlyHorizontal = chartDirectionCue([ + { x: 0, y: 100 }, + { x: 320, y: 100 + 1e-9 } + ]) + assert.strictEqual(nearlyHorizontal?.start.y, nearlyHorizontal?.end.y) + assert.closeTo((nearlyHorizontal?.end.x ?? 0) - (nearlyHorizontal?.start.x ?? 0), 10, 1e-9) + assert.strictEqual(chartDirectionCue([{ x: 0, y: 0 }, { x: 200, y: 0 }]), null) + }) + it("keeps an orthogonal terminal segment clear for the arrowhead", () => { assert.deepStrictEqual( ensureChartEdgeTerminalClearance([ @@ -119,12 +145,14 @@ describe("Static chart", () => { assert.deepStrictEqual(policy.children(null).map(({ path }) => path), ["Idle", "Done", "Detached"]) assert.deepStrictEqual(policy.node("Idle"), { reachable: true, + staticPath: true, rank: 0, order: 0, layerConstraint: null }) assert.strictEqual(policy.node("Done").layerConstraint, "LAST") assert.strictEqual(policy.node("Detached").reachable, false) + assert.strictEqual(policy.node("Detached").staticPath, false) assert.deepStrictEqual(policy.edge(model.edges[0]!), { direction: "forward", sourceSide: "EAST", @@ -273,6 +301,63 @@ describe("Static chart", () => { assert.isAtMost(edge.label.y + edge.labelHeight / 2, parent.y + parent.height) }) + it("stacks parallel regions as vertical lanes", async () => { + const model = makeChartModel(MachineDocument.make(parallelCompletionMachine)) + const policy = makeChartLayoutPolicy(model) + const layout = await Effect.runPromise(layoutChart(model)) + const regions = layout.nodes + .filter(({ node }) => node.parent === "Order") + .sort((left, right) => left.y - right.y) + + assert.deepStrictEqual( + regions.map(({ node }) => node.path).sort(), + ["Order.payment", "Order.fulfillment"].sort() + ) + assert.isTrue(regions.every(({ node }) => policy.node(node.path).staticPath)) + assert.isAtMost(regions[0]!.y + regions[0]!.height, regions[1]!.y) + }) + + it("groups states without a static path from the initial state", async () => { + const node = (path: string, initial = false): ChartNode => ({ + path, + label: path, + type: "atomic", + parent: null, + children: [], + active: false, + initial, + fields: [], + activities: [] + }) + const model: ChartModel = { + machineId: "unconnected-region", + roots: ["Idle", "Detached"], + nodes: [node("Idle", true), node("Detached")], + edges: [], + runtimeTargets: [], + initials: [{ id: "initial:Idle", target: "Idle", parent: null }] + } + const layout = await Effect.runPromise(layoutChart(model)) + const region = layout.regions[0] + const detached = layout.nodes.find(({ node }) => node.path === "Detached") + + assert.deepStrictEqual(region?.nodePaths, ["Detached"]) + assert.isAtLeast(detached?.x ?? 0, region?.x ?? Number.POSITIVE_INFINITY) + assert.isAtMost( + (detached?.x ?? Number.POSITIVE_INFINITY) + (detached?.width ?? 0), + (region?.x ?? 0) + (region?.width ?? 0) + ) + }) + + it("separates the disconnected example state from the live topology", async () => { + const model = makeChartModel(MachineDocument.make(transitionSemanticsMachine)) + const policy = makeChartLayoutPolicy(model) + const layout = await Effect.runPromise(layoutChart(model)) + + assert.strictEqual(policy.node("Disabled").staticPath, false) + assert.isTrue(layout.regions.some(({ nodePaths }) => nodePaths.includes("Disabled"))) + }) + it("lays out runtime-resolved targets as explicit stubs", async () => { const document = MachineDocument.make(plannerMachine) const source = document.states.find(({ path }) => path === "Idle")! diff --git a/packages/devtools/test/internal/browser/VisualizerAnalysis.test.ts b/packages/devtools/test/internal/browser/VisualizerAnalysis.test.ts new file mode 100644 index 0000000..f22230a --- /dev/null +++ b/packages/devtools/test/internal/browser/VisualizerAnalysis.test.ts @@ -0,0 +1,67 @@ +import { assert, describe, it } from "@effect/vitest" +import { transitionSemanticsMachine } from "../../../src/internal/browser/transition-semantics-example.js" +import { analyzeVisualization } from "../../../src/internal/browser/visualizer-analysis.js" +import * as MachineDocument from "../../../src/MachineDocument.js" + +describe("Visualizer analysis", () => { + it("reports declared public events without a registered handler", () => { + const analysis = analyzeVisualization(MachineDocument.make(transitionSemanticsMachine)) + + assert.deepStrictEqual(analysis.warnings, [{ + _tag: "UnhandledPublicEvent", + event: "Archive" + }]) + }) + + it("reports the roots of isolated topology without repeating their descendants", () => { + const document = MachineDocument.make(transitionSemanticsMachine) + const disabled = document.states.find(({ path }) => path === "Disabled")! + const analysis = analyzeVisualization({ + ...document, + states: [ + ...document.states.map((state) => + state.path === "Disabled" + ? { ...state, type: "compound" as const, children: ["Disabled.Offline"] } + : state + ), + { + ...disabled, + path: "Disabled.Offline", + key: "Offline", + order: 0, + parent: "Disabled", + children: [], + transitionIds: [], + activityIds: [] + } + ] + }) + + assert.deepStrictEqual(analysis.topologyNotes, [{ + _tag: "NoStaticPathFromInitial", + path: "Disabled", + label: "Disabled", + type: "compound", + descendantCount: 1 + }]) + }) + + it("does not confuse an event handled by an isolated state with an unregistered event", () => { + const document = MachineDocument.make(transitionSemanticsMachine) + const archive = document.inputs.events.find(({ event }) => event === "Archive")! + const analysis = analyzeVisualization({ + ...document, + inputs: { ...document.inputs, events: [archive] }, + transitions: [{ + id: "Disabled:transition:0", + source: "Disabled", + trigger: { type: "event", event: "Archive" }, + reenter: false, + acceptance: "required", + branches: [] + }] + }) + + assert.deepStrictEqual(analysis.warnings, []) + }) +})