diff --git a/.changeset/quiet-state-charts.md b/.changeset/quiet-state-charts.md new file mode 100644 index 0000000..cb0d33f --- /dev/null +++ b/.changeset/quiet-state-charts.md @@ -0,0 +1,9 @@ +--- +"@typeonce/effect-machine-devtools": patch +--- + +Make statecharts denser by flowing states from top to bottom, replacing full initial-entry lanes with compact top-entry markers, and sizing state cards from their visible names and invocations. + +State values now appear as compact JSON-shaped type previews in the state inspector instead of occupying the topology. Transition labels sit directly on clear route segments when space permits, hierarchy-crossing routes avoid compound-state headers, and routes attach to their actual source and target before turning. Horizontally scrolling machine tabs also keep their position when selecting or live-reloading a machine. + +Charts remain available when every deterministic layout has only cosmetic label-to-route crossings. Structural failures such as detached edges, node crossings, and overlapping routes still prevent rendering. diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 57d0533..12dbdfb 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -85,7 +85,7 @@ Run the devtools only against code you trust. The server has no authentication a ## Inspection and walkthroughs -The visualizer shows topology as a read-only statechart with native horizontal and vertical scrolling, incremental zoom, and a fit-to-viewport overview. Machine tabs run across the top so the chart uses the rest of the viewport. States remain grouped inside their compound parents, while orthogonal routes connect each enabled transition without requiring a draggable canvas. State cards show projected value fields and invocations at a glance. A single click selects a state or transition, while a double click opens its dismissible inspector. State selection distinguishes incoming from outgoing relationships, and conditional branches with the same source and target share one topology edge while retaining their full details in the inspector. +The visualizer shows topology as a read-only statechart with native horizontal and vertical scrolling, incremental zoom, and a fit-to-viewport overview. Machine tabs run across the top so the chart uses the rest of the viewport. The default flow runs from top to bottom, while parallel regions remain side by side and states stay grouped inside their compound parents. Orthogonal routes connect each enabled transition without requiring a draggable canvas. Compact state cards keep names and invocations visible at a glance; select a state to inspect its projected value fields. A single click selects a state or transition, while a double click opens its dismissible inspector. State selection distinguishes incoming from outgoing relationships, and conditional branches with the same source and target share one topology edge while retaining their full details in the inspector. The machine document includes projected value and output schemas for every state, plus the public machine and event input contracts. The browser renders those contracts as read-only field metadata: names, projected types, required or optional status, descriptions, ranges, lengths, patterns, and literal or enum values. It never asks for payload values merely to explore a static document. diff --git a/packages/devtools/src/internal/browser/chart-layout-policy.ts b/packages/devtools/src/internal/browser/chart-layout-policy.ts index 7b22202..fc90353 100644 --- a/packages/devtools/src/internal/browser/chart-layout-policy.ts +++ b/packages/devtools/src/internal/browser/chart-layout-policy.ts @@ -7,7 +7,7 @@ export interface ChartNodeLayoutPolicy { readonly staticPath: boolean readonly rank: number | null readonly order: number - readonly layerConstraint: "LAST" | null + readonly layerConstraint: "FIRST" | "LAST" | null } export interface ChartEdgeLayoutPolicy { @@ -159,17 +159,17 @@ export const makeChartLayoutPolicy = (model: ChartModel): ChartLayoutPolicy => { staticPath: staticPaths.has(node.path), rank, order, - layerConstraint: node.type === "final" ? "LAST" : null + layerConstraint: rank === 0 ? "FIRST" : 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" } + return { direction: "self", sourceSide: "EAST", targetSide: "EAST" } } if (edge.target === null) { - return { direction: "forward", sourceSide: "EAST", targetSide: "WEST" } + return { direction: "forward", sourceSide: "SOUTH", targetSide: "NORTH" } } const sourceLineage = lineage(edge.source, nodes) @@ -182,10 +182,10 @@ export const makeChartLayoutPolicy = (model: ChartModel): ChartLayoutPolicy => { differentAt++ } if (differentAt === sourceLineage.length) { - return { direction: "forward", sourceSide: "EAST", targetSide: "WEST" } + return { direction: "forward", sourceSide: "EAST", targetSide: "EAST" } } if (differentAt === targetLineage.length) { - return { direction: "backward", sourceSide: "WEST", targetSide: "EAST" } + return { direction: "backward", sourceSide: "WEST", targetSide: "WEST" } } const source = nodePolicies.get(sourceLineage[differentAt]!) ?? unreachableNode @@ -193,9 +193,14 @@ export const makeChartLayoutPolicy = (model: ChartModel): ChartLayoutPolicy => { const backward = source.rank !== null && target.rank !== null ? target.rank < source.rank || target.rank === source.rank && target.order < source.order : target.order < source.order + if (nodes.get(edge.source)?.parent !== nodes.get(edge.target)?.parent) { + return backward + ? { direction: "backward", sourceSide: "WEST", targetSide: "WEST" } + : { direction: "forward", sourceSide: "EAST", targetSide: "EAST" } + } return backward - ? { direction: "backward", sourceSide: "WEST", targetSide: "EAST" } - : { direction: "forward", sourceSide: "EAST", targetSide: "WEST" } + ? { direction: "backward", sourceSide: "NORTH", targetSide: "SOUTH" } + : { direction: "forward", sourceSide: "SOUTH", targetSide: "NORTH" } } return { diff --git a/packages/devtools/src/internal/browser/chart-layout.ts b/packages/devtools/src/internal/browser/chart-layout.ts index fd42d52..8a9a1d6 100644 --- a/packages/devtools/src/internal/browser/chart-layout.ts +++ b/packages/devtools/src/internal/browser/chart-layout.ts @@ -12,9 +12,9 @@ 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 export const maxVisibleActivities = 3 export const chartSelfLoopMinimumClearance = 24 +export const chartEdgeLabelSpacing = 5 export interface ChartPoint { readonly x: number @@ -82,6 +82,8 @@ export interface LaidOutChart { } export type ChartLayoutIssueCode = + | "detached-source" + | "detached-terminal" | "missing-edge" | "label-detached" | "label-label-overlap" @@ -91,7 +93,10 @@ export type ChartLayoutIssueCode = | "route-overlap" | "self-loop-clearance" | "self-loop-outside-parent" + | "short-source" | "short-terminal" + | "wrong-source-direction" + | "wrong-terminal-direction" export interface ChartLayoutIssue { readonly code: ChartLayoutIssueCode @@ -149,82 +154,98 @@ const layoutProfiles: ReadonlyArray = [ { id: "compact-fixed", portConstraints: "fixed", - nodeSpacing: 64, - layerSpacing: 148, - edgeNodeSpacing: 42, - edgeEdgeSpacing: 26, - compoundNodeSpacing: 44, - compoundLayerSpacing: 108, - selfLoopSpacing: 32, - padding: 44 + nodeSpacing: 48, + layerSpacing: 72, + edgeNodeSpacing: 28, + edgeEdgeSpacing: 18, + compoundNodeSpacing: 32, + compoundLayerSpacing: 60, + selfLoopSpacing: 40, + padding: 36 }, { id: "spacious-fixed", portConstraints: "fixed", - nodeSpacing: 88, - layerSpacing: 188, - edgeNodeSpacing: 58, - edgeEdgeSpacing: 38, - compoundNodeSpacing: 64, - compoundLayerSpacing: 144, - selfLoopSpacing: 40, - padding: 56 + nodeSpacing: 68, + layerSpacing: 96, + edgeNodeSpacing: 42, + edgeEdgeSpacing: 28, + compoundNodeSpacing: 48, + compoundLayerSpacing: 80, + selfLoopSpacing: 48, + padding: 48 }, { id: "roomy-fixed", portConstraints: "fixed", - nodeSpacing: 112, - layerSpacing: 232, - edgeNodeSpacing: 76, - edgeEdgeSpacing: 52, - compoundNodeSpacing: 82, - compoundLayerSpacing: 180, - selfLoopSpacing: 48, - padding: 68 + nodeSpacing: 88, + layerSpacing: 124, + edgeNodeSpacing: 58, + edgeEdgeSpacing: 40, + compoundNodeSpacing: 64, + compoundLayerSpacing: 104, + selfLoopSpacing: 56, + padding: 60 }, { id: "spacious-relaxed", portConstraints: "relaxed", - nodeSpacing: 88, - layerSpacing: 188, - edgeNodeSpacing: 58, - edgeEdgeSpacing: 38, - compoundNodeSpacing: 64, - compoundLayerSpacing: 144, - selfLoopSpacing: 40, - padding: 56 + nodeSpacing: 68, + layerSpacing: 96, + edgeNodeSpacing: 42, + edgeEdgeSpacing: 28, + compoundNodeSpacing: 48, + compoundLayerSpacing: 80, + selfLoopSpacing: 48, + padding: 48 }, { id: "roomy-relaxed", portConstraints: "relaxed", - nodeSpacing: 112, - layerSpacing: 232, - edgeNodeSpacing: 76, - edgeEdgeSpacing: 52, - compoundNodeSpacing: 82, - compoundLayerSpacing: 180, - selfLoopSpacing: 48, - padding: 68 + nodeSpacing: 88, + layerSpacing: 124, + edgeNodeSpacing: 58, + edgeEdgeSpacing: 40, + compoundNodeSpacing: 64, + compoundLayerSpacing: 104, + selfLoopSpacing: 56, + padding: 60 } ] -const sectionHeight = (length: number, limit: number): number => { +const activitySectionHeight = (length: number): number => { if (length === 0) return 0 - const visible = Math.min(length, limit) - return 24 + visible * 22 + (length > limit ? 18 : 0) + const visible = Math.min(length, maxVisibleActivities) + return 16 + visible * 20 + (length > maxVisibleActivities ? 16 : 0) } -const nodeMetric = (node: ChartNode): NodeMetric => { - const headerHeight = Math.max( - 78, - 76 + - sectionHeight(node.fields.length, maxVisibleFields) + - sectionHeight(node.activities.length, maxVisibleActivities) +const approximateTextWidth = (value: string, characterWidth: number): number => value.length * characterWidth + +const nodeMetric = (node: ChartNode, selfLoops: number): NodeMetric => { + const headerHeight = 52 + activitySectionHeight(node.activities.length) + const nameWidth = approximateTextWidth(node.label, 7.2) + 55 + const activityWidth = node.activities.reduce( + (width, activity) => + Math.max( + width, + approximateTextWidth(activity.kind.toUpperCase(), 5.5) + + approximateTextWidth(activity.label, 6.1) + 48 + ), + 0 ) - const width = node.type === "choice" || node.type === "history" ? 176 : node.children.length > 0 ? 340 : 276 + const minimumWidth = node.type === "choice" || node.type === "history" + ? 132 + : node.children.length > 0 + ? 220 + : 144 + const selfLoopWidth = selfLoops <= 1 ? 0 : 144 + (selfLoops - 1) * 44 + const selfLoopHeight = selfLoops <= 1 ? 0 : 52 + (selfLoops - 1) * 16 + const width = Math.min(320, Math.max(minimumWidth, nameWidth, activityWidth, selfLoopWidth)) return { width, - height: node.children.length === 0 ? headerHeight : Math.max(240, headerHeight + 104), + height: node.children.length === 0 + ? Math.max(headerHeight, selfLoopHeight) + : Math.max(180, headerHeight + 88), headerHeight } } @@ -234,14 +255,20 @@ const elk = new ELK() const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source` const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target` -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 const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`) +const selfLoopsBySource = (edges: ReadonlyArray): ReadonlyMap => { + const counts = new Map() + for (const edge of edges) { + if (isSelfTransition(edge)) counts.set(edge.source, (counts.get(edge.source) ?? 0) + 1) + } + return counts +} + const unconnectedRegions = ( model: ChartModel, policy: ReturnType @@ -306,12 +333,7 @@ const makeGraph = ( const regionsByParent = new Map(regions.map((region) => [region.parent, region])) const sourceByEdgeId = new Map(model.edges.map((edge) => [edge.id, edge.source])) const runtimeByEdgeId = new Map(model.runtimeTargets.map((target) => [target.edgeId, target])) - const initialsByParent = new Map>() - for (const initial of model.initials) { - const siblings = initialsByParent.get(initial.parent) ?? [] - siblings.push(initial) - initialsByParent.set(initial.parent, siblings) - } + const selfLoops = selfLoopsBySource(model.edges) const runtimeTargetsByParent = new Map>() for (const target of model.runtimeTargets) { const siblings = runtimeTargetsByParent.get(target.parent) ?? [] @@ -319,25 +341,6 @@ const makeGraph = ( runtimeTargetsByParent.set(target.parent, siblings) } const ports = portsByState(model.edges, policy.edge, profile.portConstraints) - for (const initial of model.initials) { - const statePorts = ports.get(initial.target) ?? [] - statePorts.push({ - id: initialTargetPortId(initial), - width: 6, - height: 6, - ...(profile.portConstraints === "fixed" - ? { layoutOptions: { "elk.port.side": "WEST" } } - : {}) - }) - ports.set(initial.target, statePorts) - } - - 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, @@ -347,7 +350,7 @@ const makeGraph = ( width: 6, height: 6, ...(profile.portConstraints === "fixed" - ? { layoutOptions: { "elk.port.side": "WEST" } } + ? { layoutOptions: { "elk.port.side": "NORTH" } } : {}) }], ...(profile.portConstraints === "fixed" @@ -356,14 +359,16 @@ const makeGraph = ( }) const stateNode = (node: ChartNode, suppressUnconnectedRegion: boolean): ElkNode => { - const metric = nodeMetric(node) + const metric = nodeMetric(node, selfLoops.get(node.path) ?? 0) const nodePolicy = policy.node(node.path) const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath) const common = { id: node.path, ports: [...ports.get(node.path) ?? []], layoutOptions: { - ...(profile.portConstraints === "fixed" ? { "elk.portConstraints": "FIXED_SIDE" } : {}), + ...(profile.portConstraints === "fixed" && node.children.length === 0 + ? { "elk.portConstraints": "FIXED_SIDE" } + : {}), "elk.spacing.portPort": "24", ...(nodePolicy.layerConstraint === null ? {} @@ -379,16 +384,15 @@ const makeGraph = ( layoutOptions: { ...common.layoutOptions, "elk.algorithm": "layered", - "elk.direction": node.type === "parallel" ? "DOWN" : "RIGHT", + "elk.direction": node.type === "parallel" ? "RIGHT" : "DOWN", "elk.padding": `[top=${metric.headerHeight + 36},left=36,bottom=36,right=36]`, "elk.nodeSize.constraints": "MINIMUM_SIZE", "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`, "elk.spacing.nodeNode": String(profile.compoundNodeSpacing), - "elk.layered.spacing.nodeNodeBetweenLayers": String( - node.type === "parallel" ? profile.compoundNodeSpacing + 24 : profile.compoundLayerSpacing - ), + "elk.layered.spacing.nodeNodeBetweenLayers": String(profile.compoundLayerSpacing), "elk.spacing.edgeNode": String(profile.edgeNodeSpacing), "elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing), + "elk.spacing.edgeLabel": String(chartEdgeLabelSpacing), "elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing), "elk.layered.spacing.edgeNodeBetweenLayers": String(profile.edgeNodeSpacing), "elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing) @@ -399,11 +403,9 @@ const makeGraph = ( 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) ?? "")) @@ -412,7 +414,6 @@ const makeGraph = ( 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) ?? "")) @@ -423,13 +424,14 @@ const makeGraph = ( children: regionChildren, layoutOptions: { "elk.algorithm": "layered", - "elk.direction": "RIGHT", + "elk.direction": "DOWN", "elk.padding": "[top=54,left=24,bottom=24,right=24]", "elk.layered.layering.layerConstraint": "LAST", "elk.spacing.nodeNode": String(profile.nodeSpacing), "elk.layered.spacing.nodeNodeBetweenLayers": String(profile.layerSpacing), "elk.spacing.edgeNode": String(profile.edgeNodeSpacing), "elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing), + "elk.spacing.edgeLabel": String(chartEdgeLabelSpacing), "elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing) } }) @@ -455,21 +457,11 @@ const makeGraph = ( "elk.layered.priority.straightness": "5" } } - }), - ...model.initials.map((initial): ElkExtendedEdge => ({ - id: initial.id, - sources: [initialNodeId(initial)], - targets: [initialTargetPortId(initial)], - layoutOptions: { - "elk.layered.priority.direction": "100", - "elk.layered.priority.shortness": "100", - "elk.layered.priority.straightness": "100" - } - })) + }) ], layoutOptions: { "elk.algorithm": "layered", - "elk.direction": "RIGHT", + "elk.direction": "DOWN", "elk.hierarchyHandling": "INCLUDE_CHILDREN", "elk.edgeRouting": "ORTHOGONAL", "elk.padding": @@ -480,6 +472,7 @@ const makeGraph = ( "elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing), "elk.spacing.edgeNode": String(profile.edgeNodeSpacing), "elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing), + "elk.spacing.edgeLabel": String(chartEdgeLabelSpacing), "elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing), "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", "elk.layered.considerModelOrder.portModelOrder": "false", @@ -564,13 +557,430 @@ const midpoint = (points: ReadonlyArray): ChartPoint => { return points.at(-1)! } +const horizontalBoundaryIntersection = ( + start: ChartPoint, + end: ChartPoint, + y: number, + left: number, + right: number +): ChartPoint | null => { + if (start.x === end.x) { + if (start.x < left || start.x > right || (start.y - y) * (end.y - y) > 0) return null + return { x: start.x, y } + } + if (start.y !== y || end.y !== y) return null + const minimum = Math.max(left, Math.min(start.x, end.x)) + const maximum = Math.min(right, Math.max(start.x, end.x)) + if (minimum > maximum) return null + return { x: start.x <= end.x ? minimum : maximum, y } +} + +const trimRouteFromCompoundHeader = ( + points: ReadonlyArray, + node: LaidOutChartNode +): ReadonlyArray => { + const boundary = node.y + node.headerHeight + for (let index = 1; index < points.length; index++) { + const intersection = horizontalBoundaryIntersection( + points[index - 1]!, + points[index]!, + boundary, + node.x, + node.x + node.width + ) + if (intersection !== null) return compactPoints([intersection, ...points.slice(index)]) + } + return points +} + +const normalizeHierarchyRoute = ( + edge: ChartEdge, + points: ReadonlyArray, + nodes: ReadonlyMap +): ReadonlyArray => { + if (edge.target !== null && isDescendantPath(edge.target, edge.source)) { + const source = nodes.get(edge.source) + return source === undefined ? points : trimRouteFromCompoundHeader(points, source) + } + if (edge.target !== null && isDescendantPath(edge.source, edge.target)) { + const target = nodes.get(edge.target) + return target === undefined + ? points + : [...trimRouteFromCompoundHeader([...points].reverse(), target)].reverse() + } + return points +} + +type RouteSide = "NORTH" | "EAST" | "SOUTH" | "WEST" + +const routeNodeRect = (node: LaidOutChartNode): ChartRect => + node.node.children.length > 0 ? nodeHeaderRect(node) : nodeRect(node) + +const expandRect = (rect: ChartRect, amount: number): ChartRect => ({ + left: rect.left - amount, + right: rect.right + amount, + top: rect.top - amount, + bottom: rect.bottom + amount +}) + +const endpointSide = (point: ChartPoint, node: LaidOutChartNode): RouteSide => { + const rect = routeNodeRect(node) + const distances: Array = [ + ["NORTH", Math.abs(point.y - rect.top)], + ["SOUTH", Math.abs(point.y - rect.bottom)], + ["WEST", Math.abs(point.x - rect.left)], + ["EAST", Math.abs(point.x - rect.right)] + ] + return distances.sort((left, right) => left[1] - right[1])[0]![0] +} + +const outwardPoint = (point: ChartPoint, side: RouteSide, distance: number): ChartPoint => { + switch (side) { + case "NORTH": + return { x: point.x, y: point.y - distance } + case "SOUTH": + return { x: point.x, y: point.y + distance } + case "WEST": + return { x: point.x - distance, y: point.y } + case "EAST": + return { x: point.x + distance, y: point.y } + } +} + +const isOutwardStep = (boundary: ChartPoint, adjacent: ChartPoint, side: RouteSide): boolean => + side === "NORTH" + ? adjacent.x === boundary.x && adjacent.y < boundary.y + : side === "SOUTH" + ? adjacent.x === boundary.x && adjacent.y > boundary.y + : side === "WEST" + ? adjacent.y === boundary.y && adjacent.x < boundary.x + : adjacent.y === boundary.y && adjacent.x > boundary.x + +const pointOnNodeBoundary = ( + point: ChartPoint, + node: LaidOutChartNode, + side: RouteSide +): ChartPoint => { + const rect = routeNodeRect(node) + switch (side) { + case "NORTH": + return { x: Math.min(rect.right, Math.max(rect.left, point.x)), y: rect.top } + case "SOUTH": + return { x: Math.min(rect.right, Math.max(rect.left, point.x)), y: rect.bottom } + case "WEST": + return { x: rect.left, y: Math.min(rect.bottom, Math.max(rect.top, point.y)) } + case "EAST": + return { x: rect.right, y: Math.min(rect.bottom, Math.max(rect.top, point.y)) } + } +} + +const nodeBoundaryDistance = (point: ChartPoint, node: LaidOutChartNode): number => { + const boundary = pointOnNodeBoundary(point, node, endpointSide(point, node)) + return Math.abs(point.x - boundary.x) + Math.abs(point.y - boundary.y) +} + +const orthogonalConnections = ( + start: ChartPoint, + end: ChartPoint +): ReadonlyArray> => { + if (start.x === end.x || start.y === end.y) return [[start, end]] + return [ + [start, { x: end.x, y: start.y }, end], + [start, { x: start.x, y: end.y }, end] + ] +} + +const endpointConnections = ( + start: ChartPoint, + end: ChartPoint, + target: ChartRect, + side: RouteSide +): ReadonlyArray> => { + const clearance = 12 + const detours = side === "EAST" || side === "WEST" + ? [target.top - clearance, target.bottom + clearance].map((y) => + compactPoints([start, { x: start.x, y }, { x: end.x, y }, end]) + ) + : [target.left - clearance, target.right + clearance].map((x) => + compactPoints([start, { x, y: start.y }, { x, y: end.y }, end]) + ) + return [...orthogonalConnections(start, end), ...detours] +} + +const routeObstacles = ( + edge: ChartEdge, + nodes: ReadonlyArray +): ReadonlyArray => + nodes.flatMap((node) => + node.node.path === edge.source || node.node.path === edge.target + ? [] + : [expandRect(routeNodeRect(node), 6)] + ) + +const routeIsClear = ( + points: ReadonlyArray, + obstacles: ReadonlyArray +): boolean => + points.slice(1).every((point, index) => { + const previous = points[index]! + return (previous.x === point.x || previous.y === point.y) && + obstacles.every((obstacle) => !segmentCrossesInterior(previous, point, obstacle)) + }) + +const sameSideRoute = ( + points: ReadonlyArray, + side: RouteSide, + lane: number +): ReadonlyArray => { + const start = points[0]! + const end = points.at(-1)! + const offset = 24 + lane * 8 + const coordinate = side === "NORTH" + ? { axis: "y" as const, value: Math.min(start.y, end.y) - offset } + : side === "SOUTH" + ? { axis: "y" as const, value: Math.max(start.y, end.y) + offset } + : side === "WEST" + ? { axis: "x" as const, value: Math.min(start.x, end.x) - offset } + : { axis: "x" as const, value: Math.max(start.x, end.x) + offset } + return coordinate.axis === "x" + ? compactPoints([start, { x: coordinate.value, y: start.y }, { x: coordinate.value, y: end.y }, end]) + : compactPoints([start, { x: start.x, y: coordinate.value }, { x: end.x, y: coordinate.value }, end]) +} + +const shortenTransitionRoute = ( + edge: ChartEdge, + points: ReadonlyArray, + nodes: ReadonlyMap, + allNodes: ReadonlyArray, + lanes: Map +): ReadonlyArray => { + if (edge.target === null || isSelfTransition(edge)) return points + const source = nodes.get(edge.source) + const target = nodes.get(edge.target) + if (source === undefined || target === undefined) return points + const sourceSide = endpointSide(points[0]!, source) + const targetSide = endpointSide(points.at(-1)!, target) + if (sourceSide !== targetSide) return points + const obstacles = routeObstacles(edge, allNodes) + const currentLength = chartRouteLength(points) + const key = `${edge.target}:${targetSide}` + const lane = lanes.get(key) ?? 0 + const candidate = sameSideRoute(points, sourceSide, lane) + if (!routeIsClear(candidate, obstacles) || chartRouteLength(candidate) + 16 >= currentLength) return points + lanes.set(key, lane + 1) + return candidate +} + +const normalizeTerminalDirection = ( + edge: ChartEdge, + points: ReadonlyArray, + nodes: ReadonlyMap, + allNodes: ReadonlyArray +): ReadonlyArray => { + if (edge.target === null || isSelfTransition(edge) || points.length < 2) return points + const target = nodes.get(edge.target) + if (target === undefined) return points + const rawEnd = points.at(-1)! + const side = endpointSide(rawEnd, target) + const end = pointOnNodeBoundary(rawEnd, target, side) + const prefix = points.slice(0, -1) + const previous = prefix.at(-1)! + const attached = compactPoints([...prefix, end]) + if (isOutwardStep(end, previous, side)) return attached + + const targetStub = outwardPoint(end, side, 18) + const obstacles = [...routeObstacles(edge, allNodes), routeNodeRect(target)] + return endpointConnections(previous, targetStub, routeNodeRect(target), side) + .filter((connection) => + !connection.slice(0, -1).some((point) => point.x === end.x && point.y === end.y) && + routeIsClear([...connection, end], obstacles) + ) + .map((connection) => compactPoints([...prefix, ...connection.slice(1), end])) + .sort((left, right) => chartRouteLength(left) - chartRouteLength(right))[0] ?? attached +} + +const normalizeSourceDirection = ( + edge: ChartEdge, + points: ReadonlyArray, + nodes: ReadonlyMap, + allNodes: ReadonlyArray +): ReadonlyArray => { + if (isSelfTransition(edge) || points.length < 2) return points + const source = nodes.get(edge.source) + if (source === undefined || source.node.children.length > 0) return points + const rawStart = points[0]! + const side = endpointSide(rawStart, source) + const start = pointOnNodeBoundary(rawStart, source, side) + const tail = points.slice(1) + const next = tail[0]! + const attached = compactPoints([start, ...tail]) + if (isOutwardStep(start, next, side)) return attached + + const sourceStub = outwardPoint(start, side, 18) + const obstacles = [...routeObstacles(edge, allNodes), routeNodeRect(source)] + return endpointConnections(sourceStub, next, routeNodeRect(source), side) + .filter((connection) => routeIsClear([start, ...connection, ...tail.slice(1)], obstacles)) + .map((connection) => compactPoints([start, ...connection, ...tail.slice(1)])) + .sort((left, right) => chartRouteLength(left) - chartRouteLength(right))[0] ?? attached +} + +const headerDetour = ( + points: ReadonlyArray, + node: LaidOutChartNode, + nodes: ReadonlyArray, + lanes: Map +): ReadonlyArray => { + const header = nodeHeaderRect(node) + for (let index = 1; index < points.length; index++) { + const start = points[index - 1]! + const end = points[index]! + if (start.x !== end.x || !segmentCrossesInterior(start, end, header)) continue + const candidates = (["left", "right"] as const).map((side) => { + const key = `${node.node.path}:${side}` + const used = lanes.get(key) ?? 0 + const laneX = side === "left" + ? header.left - 12 - used * 8 + : header.right + 12 + used * 8 + const route = compactPoints([ + ...points.slice(0, index), + { x: laneX, y: start.y }, + { x: laneX, y: end.y }, + end, + ...points.slice(index + 1) + ]) + const crossings = nodes.reduce((count, obstacleNode) => { + if (obstacleNode.node.path === node.node.path) return count + const obstacle = obstacleNode.node.children.length > 0 + ? nodeHeaderRect(obstacleNode) + : nodeRect(obstacleNode) + return count + + route.slice(1).filter((point, segmentIndex) => segmentCrossesInterior(route[segmentIndex]!, point, obstacle)) + .length + }, 0) + return { + key, + route, + score: crossings * 1_000_000 + used * 10_000 + chartRouteLength(route) + } + }) + const selected = candidates.sort((left, right) => left.score - right.score)[0]! + lanes.set(selected.key, (lanes.get(selected.key) ?? 0) + 1) + return selected.route + } + return points +} + +const avoidCompoundHeaders = ( + edge: ChartEdge, + points: ReadonlyArray, + nodes: ReadonlyArray, + lanes: Map +): ReadonlyArray => { + let routed = points + for (const node of nodes) { + if (node.node.children.length === 0 || !transitionTouchesNode(edge, node.node.path)) continue + if (!routed.slice(1).some((point, index) => segmentCrossesInterior(routed[index]!, point, nodeHeaderRect(node)))) { + continue + } + routed = headerDetour(routed, node, nodes, lanes) + } + return routed +} + +const routeLabelCandidates = ( + edge: ChartEdge, + points: ReadonlyArray, + fallback: ChartPoint, + width: number, + height: number +): ReadonlyArray => { + if (isSelfTransition(edge)) return [fallback] + const candidates = points.slice(1).flatMap((end, index) => { + const start = points[index]! + const horizontal = start.y === end.y + const length = Math.abs(end.x - start.x) + Math.abs(end.y - start.y) + const required = (horizontal ? width : height) + 24 + return length < required ? [] : [{ start, end, horizontal, length }] + }) + const ordered = [ + ...candidates.filter(({ horizontal }) => !horizontal).sort((left, right) => right.length - left.length), + ...candidates.filter(({ horizontal }) => horizontal).sort((left, right) => right.length - left.length) + ].flatMap(({ start, end, horizontal, length }) => { + const clearance = (horizontal ? width : height) / 2 + 12 + return [0.5, 2 / 3, 1 / 3].flatMap((ratio) => { + const distance = length * ratio + if (distance < clearance || length - distance < clearance) return [] + return [{ + x: start.x + (end.x - start.x) * ratio, + y: start.y + (end.y - start.y) * ratio + }] + }) + }) + return [...ordered, fallback].filter((candidate, index, all) => + all.findIndex((other) => other.x === candidate.x && other.y === candidate.y) === index + ) +} + +const placeTransitionLabels = ( + transitions: ReadonlyArray, + nodes: ReadonlyArray +): ReadonlyArray => { + const placed: Array = [] + for (const transition of transitions) { + const candidates = routeLabelCandidates( + transition.edge, + transition.points, + transition.label, + transition.labelWidth, + transition.labelHeight + ) + const position = candidates.find((candidate) => { + const candidateRect = labelRect(candidate, transition.labelWidth, transition.labelHeight) + if ( + nodes.some((node) => { + const touchesCompound = node.node.children.length > 0 && transitionTouchesNode( + transition.edge, + node.node.path + ) + return overlaps(candidateRect, touchesCompound ? nodeHeaderRect(node) : nodeRect(node), 2) + }) + ) return false + if ( + placed.some((other) => + overlaps( + candidateRect, + labelRect(other.label, other.labelWidth, other.labelHeight), + 2 + ) + ) + ) return false + if ( + transitions.some((other) => + other.edge.id !== transition.edge.id && overlaps( + candidateRect, + labelRect(other.label, other.labelWidth, other.labelHeight), + 2 + ) + ) + ) return false + return transitions.every((other) => + other.edge.id === transition.edge.id || + !other.points.slice(1).some((point, index) => + segmentCrossesInterior(other.points[index]!, point, candidateRect) + ) + ) + }) ?? transition.label + placed.push({ ...transition, label: position }) + } + return placed +} + 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 }]]) @@ -578,6 +988,7 @@ const collectLayout = ( const nodes: Array = [] const initials: Array = [] const runtimeTargets: Array = [] + const selfLoops = selfLoopsBySource(model.edges) const visit = (node: ElkNode, parentOffset: ChartPoint): void => { const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 }) @@ -596,7 +1007,7 @@ const collectLayout = ( } const chartNode = chartNodes.get(node.id) if (chartNode !== undefined) { - const metric = nodeMetric(chartNode) + const metric = nodeMetric(chartNode, selfLoops.get(chartNode.path) ?? 0) nodes.push({ node: chartNode, x: absolute.x, @@ -606,16 +1017,6 @@ const collectLayout = ( headerHeight: metric.headerHeight }) } - const initial = chartInitials.get(node.id) - if (initial !== undefined) { - initials.push({ - initial, - x: absolute.x, - y: absolute.y, - width: node.width ?? 14, - height: node.height ?? 14 - }) - } const runtimeTarget = chartRuntimeTargets.get(node.id) if (runtimeTarget !== undefined) { runtimeTargets.push({ @@ -630,39 +1031,90 @@ const collectLayout = ( } for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 }) + const nodesByPath = new Map(nodes.map((node) => [node.node.path, node])) + for (const initial of model.initials) { + const target = nodesByPath.get(initial.target) + if (target === undefined) continue + initials.push({ + initial, + x: target.x + 9, + y: target.y - 17, + width: 7, + height: 7 + }) + } + const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge])) - const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial])) - const edges = (graph.edges ?? []).flatMap( - (edge): ReadonlyArray => { + const hierarchyLanes = new Map() + const directLanes = new Map() + const rawTransitionEdges = (graph.edges ?? []).flatMap( + (edge): ReadonlyArray => { const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 } - const points = edgePoints(edge, offset) - if (points === undefined) return [] + const elkPoints = edgePoints(edge, offset) + if (elkPoints === undefined) return [] const chartEdge = chartEdges.get(edge.id) - if (chartEdge !== undefined) { - const metric = labelMetric(chartEdge.label) - const label = edge.labels?.[0] - const labelWidth = label?.width ?? metric.width - const labelHeight = label?.height ?? metric.height - return [{ - kind: "transition", - edge: chartEdge, - points, - label: label?.x === undefined || label.y === undefined - ? midpoint(points) - : add(offset, { - x: label.x + labelWidth / 2, - y: label.y + labelHeight / 2 - }), - labelWidth, - labelHeight - }] - } - const initial = initialEdges.get(edge.id) - return initial === undefined ? [] : [{ kind: "initial", initial, points }] + if (chartEdge === undefined) return [] + const points = normalizeTerminalDirection( + chartEdge, + normalizeSourceDirection( + chartEdge, + avoidCompoundHeaders( + chartEdge, + shortenTransitionRoute( + chartEdge, + normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath), + nodesByPath, + nodes, + directLanes + ), + nodes, + hierarchyLanes + ), + nodesByPath, + nodes + ), + nodesByPath, + nodes + ) + const metric = labelMetric(chartEdge.label) + const label = edge.labels?.[0] + const labelWidth = label?.width ?? metric.width + const labelHeight = label?.height ?? metric.height + const elkLabel = label?.x === undefined || label.y === undefined + ? midpoint(points) + : add(offset, { + x: label.x + labelWidth / 2, + y: label.y + labelHeight / 2 + }) + const routeChanged = points.length !== elkPoints.length || + points.slice(1, -1).some((point, index) => + point.x !== elkPoints[index + 1]?.x || point.y !== elkPoints[index + 1]?.y + ) + return [{ + kind: "transition", + edge: chartEdge, + points, + label: !isSelfTransition(chartEdge) && routeChanged ? midpoint(points) : elkLabel, + labelWidth, + labelHeight + }] } ) - - const transitionEdges = edges.filter((edge) => edge.kind === "transition") + const initialEdges = initials.flatMap(({ initial, x, y, width, height }): ReadonlyArray => { + const target = nodesByPath.get(initial.target) + if (target === undefined) return [] + const start = { x: x + width / 2, y: y + height / 2 } + return [{ + kind: "initial", + initial, + points: compactPoints([start, { x: start.x, y: target.y }]) + }] + }) + const transitionEdges = placeTransitionLabels(rawTransitionEdges, nodes) + const edges: ReadonlyArray = [ + ...transitionEdges, + ...initialEdges + ] const contentWidth = Math.max( 0, ...nodes.map(({ width, x }) => x + width), @@ -872,16 +1324,44 @@ export const validateChartLayout = ( if (transitionLabelDistance > Math.max(transition.labelWidth, transition.labelHeight) / 2 + 12) { report("label-detached", transition.edge.id, `${Math.round(transitionLabelDistance)}px`) } + const start = transition.points[0] + const next = transition.points[1] + if (start !== undefined && next !== undefined && !isSelfTransition(transition.edge)) { + const source = layout.nodes.find(({ node }) => node.path === transition.edge.source) + if (source !== undefined && source.node.children.length === 0) { + if (Math.abs(start.x - next.x) + Math.abs(start.y - next.y) < 9) { + report("short-source", transition.edge.id) + } + if (nodeBoundaryDistance(start, source) > 0.5) { + report("detached-source", transition.edge.id, transition.edge.source) + } else if (!isOutwardStep(start, next, endpointSide(start, source))) { + report("wrong-source-direction", transition.edge.id, transition.edge.source) + } + } + } + const end = transition.points.at(-1) const bend = transition.points.at(-2) if ( end === undefined || bend === undefined || Math.abs(end.x - bend.x) + Math.abs(end.y - bend.y) < 9 ) report("short-terminal", transition.edge.id) + if (end !== undefined && transition.edge.target !== null && !isSelfTransition(transition.edge)) { + const target = layout.nodes.find(({ node }) => node.path === transition.edge.target) + if (target !== undefined && nodeBoundaryDistance(end, target) > 0.5) { + report("detached-terminal", transition.edge.id, transition.edge.target) + } else if (target !== undefined && bend !== undefined && !isOutwardStep(end, bend, endpointSide(end, target))) { + report("wrong-terminal-direction", transition.edge.id, transition.edge.target) + } + } const label = labelRect(transition.label, transition.labelWidth, transition.labelHeight) for (const node of layout.nodes) { - const obstacle = node.node.children.length > 0 && transitionTouchesNode(transition.edge, node.node.path) + const touchesCompound = node.node.children.length > 0 && transitionTouchesNode( + transition.edge, + node.node.path + ) + const obstacle = touchesCompound ? nodeHeaderRect(node) : nodeRect(node) if (overlaps(label, obstacle, 2)) report("label-node-overlap", transition.edge.id, node.node.path) @@ -970,6 +1450,7 @@ export const validateChartLayout = ( } type ChartLayoutEngine = (graph: ElkNode, portConstraints: PortConstraints) => Promise +type ChartLayoutValidator = (model: ChartModel, layout: LaidOutChart) => ChartLayoutValidation interface LayoutAttemptFailure { readonly profile: string @@ -978,9 +1459,18 @@ interface LayoutAttemptFailure { interface InvalidLayoutCandidate { readonly profile: string + readonly layout: LaidOutChart readonly validation: ChartLayoutValidation } +const isWarningOnlyLayout = ({ issues }: ChartLayoutValidation): boolean => + issues.length > 0 && issues.every(({ code }) => code === "label-route-overlap") + +const bestLayoutCandidate = ( + candidates: ReadonlyArray +): InvalidLayoutCandidate | undefined => + [...candidates].sort((left, right) => validationScore(left.validation) - validationScore(right.validation))[0] + const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause) const issueSummary = (validation: ChartLayoutValidation): string => { @@ -995,7 +1485,8 @@ const issueSummary = (validation: ChartLayoutValidation): string => { export const layoutChartWith = ( model: ChartModel, - layout: ChartLayoutEngine + layout: ChartLayoutEngine, + validate: ChartLayoutValidator = validateChartLayout ): Effect.Effect => Effect.suspend(() => { const policy = makeChartLayoutPolicy(model) @@ -1006,9 +1497,9 @@ export const layoutChartWith = ( const attempt = (index: number): Effect.Effect => { const profile = layoutProfiles[index] if (profile === undefined) { - const best = [...invalid].sort((left, right) => - validationScore(left.validation) - validationScore(right.validation) - )[0] + const fallback = bestLayoutCandidate(invalid.filter(({ validation }) => isWarningOnlyLayout(validation))) + if (fallback !== undefined) return Effect.succeed(fallback.layout) + const best = bestLayoutCandidate(invalid) const detail = best === undefined ? failures.map(({ cause, profile }) => `${profile}: ${causeMessage(cause)}`).join("; ") : `${best.profile}: ${issueSummary(best.validation)}` @@ -1032,9 +1523,9 @@ export const layoutChartWith = ( }, onSuccess: (graph) => { const candidate = collectLayout(model, graph, regions) - const validation = validateChartLayout(model, candidate) + const validation = validate(model, candidate) if (validation.valid) return Effect.succeed(candidate) - invalid.push({ profile: profile.id, validation }) + invalid.push({ profile: profile.id, layout: candidate, validation }) return attempt(index + 1) } } diff --git a/packages/devtools/src/internal/browser/chart-model.ts b/packages/devtools/src/internal/browser/chart-model.ts index 44fcd52..0d9ebe8 100644 --- a/packages/devtools/src/internal/browser/chart-model.ts +++ b/packages/devtools/src/internal/browser/chart-model.ts @@ -5,16 +5,8 @@ import type { State as VisualizationState, Transition as VisualizationTransition } from "../../MachineDocument.js" -import { type InputField, projectInputSchema } from "./input-form.js" import { stateLabel, triggerLabel } from "./visualizer-model.js" -export interface ChartField { - readonly key: string - readonly label: string - readonly type: string - readonly required: boolean -} - export interface ChartActivity { readonly id: string readonly kind: VisualizationActivity["type"] @@ -29,7 +21,6 @@ export interface ChartNode { readonly children: ReadonlyArray readonly active: boolean readonly initial: boolean - readonly fields: ReadonlyArray readonly activities: ReadonlyArray } @@ -69,53 +60,6 @@ export interface ChartModel { readonly initials: ReadonlyArray } -const literalType = (value: string | number | boolean | null): string => - typeof value === "string" ? JSON.stringify(value) : String(value) - -const fieldType = (field: InputField): string => { - switch (field._tag) { - case "String": - return field.format ?? "string" - case "Number": - return field.integer ? "integer" : "number" - case "Boolean": - return "boolean" - case "Enum": - return field.values.map(literalType).join(" | ") - case "Literal": - return literalType(field.value) - case "Object": - return "object" - case "Array": - return `${fieldType(field.item)}[]` - case "Union": - return field.alternatives.map(fieldType).join(" | ") - case "Unsupported": - return "unknown" - } -} - -const stateFields = (state: VisualizationState): ReadonlyArray => { - if (state.valueSchema === null) return [] - const projected = projectInputSchema(state.valueSchema) - if (projected._tag !== "Object") { - return [{ - key: "value", - label: projected.title ?? "value", - type: fieldType(projected), - required: true - }] - } - return projected.fields - .filter(({ key }) => key !== "_tag") - .map(({ field, key, required }) => ({ - key, - label: field.title ?? key, - type: fieldType(field), - required - })) -} - const activityLabel = (activity: VisualizationActivity): string => { switch (activity.type) { case "machine": @@ -177,7 +121,6 @@ export const makeChartModel = (document: VisualizationDocument): ChartModel => { children: [...state.children], active: active.has(state.path), initial: initialPaths.has(state.path), - fields: stateFields(state), activities: state.activityIds.flatMap((id): ReadonlyArray => { const activity = activities.get(id) return activity === undefined ? [] : [{ id, kind: activity.type, label: activityLabel(activity) }] diff --git a/packages/devtools/src/internal/browser/chart-renderer.ts b/packages/devtools/src/internal/browser/chart-renderer.ts index 01eb719..48c4e34 100644 --- a/packages/devtools/src/internal/browser/chart-renderer.ts +++ b/packages/devtools/src/internal/browser/chart-renderer.ts @@ -6,8 +6,7 @@ import { type LaidOutChart, type LaidOutChartNode, layoutChart, - maxVisibleActivities, - maxVisibleFields + maxVisibleActivities } from "./chart-layout.js" import { makeChartModel } from "./chart-model.js" @@ -130,7 +129,7 @@ const statusLabel = (active: boolean, initial: boolean): string => { const status = (active: boolean, initial: boolean): HTMLSpanElement => { const dot = element( "span", - `chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}` + `chart-state-status${active ? " is-active" : ""}` ) dot.dataset.initial = String(initial) dot.setAttribute("aria-label", statusLabel(active, initial)) @@ -147,26 +146,8 @@ const stateContent = (layout: LaidOutChartNode, stateStatus: HTMLElement): Docum heading.append(identity) fragment.append(heading) - if (layout.node.fields.length > 0) { - const fields = element("div", "chart-state-section") - fields.append(element("div", "chart-section-label", "value")) - layout.node.fields.slice(0, maxVisibleFields).forEach((field) => { - const row = element("div", "chart-field-row") - row.append( - element("span", "chart-field-name", `${field.label}${field.required ? "" : "?"}`), - element("span", "chart-field-type", field.type) - ) - fields.append(row) - }) - if (layout.node.fields.length > maxVisibleFields) { - fields.append(more(layout.node.fields.length - maxVisibleFields)) - } - fragment.append(fields) - } - if (layout.node.activities.length > 0) { 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 chart-activity-${activity.kind}`) row.append( @@ -314,7 +295,19 @@ const render = ( const directionArrow = svgElement("path") directionArrow.setAttribute("d", "M 1 1 L 7 4 L 1 7 z") directionMarker.append(directionArrow) - definitions.append(marker, directionMarker) + const initialMarker = svgElement("marker") + initialMarker.id = "chart-initial-arrow" + initialMarker.setAttribute("viewBox", "0 0 8 8") + initialMarker.setAttribute("refX", "7") + initialMarker.setAttribute("refY", "4") + initialMarker.setAttribute("markerWidth", "7") + initialMarker.setAttribute("markerHeight", "7") + initialMarker.setAttribute("markerUnits", "userSpaceOnUse") + initialMarker.setAttribute("orient", "auto") + const initialArrow = svgElement("path") + initialArrow.setAttribute("d", "M 1 1 L 7 4 L 1 7 z") + initialMarker.append(initialArrow) + definitions.append(marker, directionMarker, initialMarker) svg.append(definitions) const nodesLayer = element("div", "chart-nodes") const labelsLayer = element("div", "chart-labels") @@ -447,7 +440,10 @@ const render = ( const route = chartEdgePathData(laidOut.points) casing.setAttribute("d", route) visible.setAttribute("d", route) - visible.setAttribute("marker-end", "url(#chart-arrow)") + visible.setAttribute( + "marker-end", + laidOut.kind === "initial" ? "url(#chart-initial-arrow)" : "url(#chart-arrow)" + ) const hit = svgElement("path", "chart-edge-hit") hit.setAttribute("d", route) group.append(casing, visible) diff --git a/packages/devtools/src/internal/browser/machine-index.ts b/packages/devtools/src/internal/browser/machine-index.ts index edbf609..30b8ee6 100644 --- a/packages/devtools/src/internal/browser/machine-index.ts +++ b/packages/devtools/src/internal/browser/machine-index.ts @@ -3,6 +3,19 @@ import { mountVisualizer } from "./visualizer.js" let selectedKey: string | undefined +export const machineIndexScrollLeft = ( + scrollLeft: number, + viewportWidth: number, + selectedLeft: number, + selectedWidth: number +): number => { + if (selectedLeft < scrollLeft) return selectedLeft + const selectedRight = selectedLeft + selectedWidth + return selectedRight > scrollLeft + viewportWidth + ? selectedRight - viewportWidth + : scrollLeft +} + const createElement = ( tag: Tag, className?: string, @@ -36,6 +49,7 @@ const statusLabel = (result: MachineResult): string => { } export const mountMachineIndex = (root: HTMLElement, snapshot: RegistrySnapshot): void => { + const previousScrollLeft = root.querySelector(".machine-index")?.scrollLeft ?? 0 const results = [...snapshot.results].sort((left, right) => resultLabel(left).localeCompare(resultLabel(right))) if (selectedKey === undefined || !results.some((result) => result.key === selectedKey)) { selectedKey = results[0]?.key @@ -78,4 +92,13 @@ export const mountMachineIndex = (root: HTMLElement, snapshot: RegistrySnapshot) shell.append(index, view) root.replaceChildren(shell) + const selected = index.querySelector(".machine-row.is-selected") + index.scrollLeft = selected === null + ? previousScrollLeft + : machineIndexScrollLeft( + previousScrollLeft, + index.clientWidth, + selected.offsetLeft, + selected.offsetWidth + ) } diff --git a/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts b/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts new file mode 100644 index 0000000..12d4bf2 --- /dev/null +++ b/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts @@ -0,0 +1,141 @@ +import { Machine } from "@typeonce/effect-machine" +import { Effect, Schema } from "effect" + +const ReplicationStates = Machine.states({ + Connecting: {}, + IdentifyingSource: {}, + ReadingServerInfo: {}, + ReadingSlot: {}, + CreatingSlot: {}, + CopyingSnapshot: {}, + CatchingUp: {}, + ApplyingChanges: {}, + Ready: {}, + SessionUnavailable: {}, + Stopping: {}, + Stopped: {}, + Failed: {} +}) + +const ReplicationEvents = Machine.events( + Schema.TaggedUnion({ + Retry: {}, + SessionUnavailable: {}, + StopRequested: {} + }) +) + +const operation = (): Effect.Effect => Effect.succeed(undefined) + +export const sharedTerminalRoutingMachine = Machine.make({ + id: "shared-terminal-routing", + states: ReplicationStates.states, + events: ReplicationEvents, + initial: (to) => to.Connecting() +}).handle({ + Connecting: { + invoke: (from) => + from.effect("connect", operation) + .onDone((to) => to.full.IdentifyingSource()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + IdentifyingSource: { + invoke: (from) => + from.effect("identify-source", operation) + .onDone((to) => to.full.ReadingServerInfo()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + ReadingServerInfo: { + invoke: (from) => + from.effect("read-server-info", operation) + .onDone((to) => to.full.ReadingSlot()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + ReadingSlot: { + invoke: (from) => + from.effect("read-slot", operation) + .onDone((to) => to.full.CreatingSlot()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + CreatingSlot: { + invoke: (from) => + from.effect("create-slot", operation) + .onDone((to) => to.full.CopyingSnapshot()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + CopyingSnapshot: { + invoke: (from) => + from.effect("copy-snapshot", operation) + .onDone((to) => to.full.CatchingUp()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + CatchingUp: { + invoke: (from) => + from.effect("catch-up", operation) + .onDone((to) => to.full.ApplyingChanges()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + ApplyingChanges: { + invoke: (from) => + from.effect("apply-changes", operation) + .onDone((to) => to.full.Ready()) + .onFailure((to) => to.full.Failed()), + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + Ready: { + on: { + SessionUnavailable: (to) => to.full.SessionUnavailable(), + StopRequested: (to) => to.full.Stopping() + } + }, + SessionUnavailable: { + on: { + Retry: (to) => to.full.Connecting(), + StopRequested: (to) => to.full.Stopping() + } + }, + Stopping: { + invoke: (from) => + from.effect("stop-session", operation) + .onDone((to) => to.full.Stopped()) + .onFailure((to) => to.full.Failed()) + }, + Stopped: {}, + Failed: { + on: { + Retry: (to) => to.full.Connecting(), + StopRequested: (to) => to.full.Stopping() + } + } +}) diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css index 534b9f7..bd0a630 100644 --- a/packages/devtools/src/internal/browser/styles.css +++ b/packages/devtools/src/internal/browser/styles.css @@ -455,8 +455,10 @@ button { .chart-state { position: absolute; - display: block; - padding: 16px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 12px; border: 1px solid #30363f; border-radius: 4px; color: #d9dce1; @@ -493,7 +495,6 @@ button { .chart-state-heading, .chart-state-identity, -.chart-field-row, .chart-activity-row { display: flex; min-width: 0; @@ -523,18 +524,7 @@ button { box-shadow: 0 0 0 3px rgb(117 167 255 / 10%); } -.chart-state-status.is-initial { - border-color: #d9a441; - background: #d9a441; - box-shadow: 0 0 0 3px rgb(217 164 65 / 12%); -} - -.chart-state-status.is-active.is-initial { - box-shadow: 0 0 0 2px #14181d, 0 0 0 4px var(--accent); -} - .chart-state-name, -.chart-field-name, .chart-activity-name { min-width: 0; overflow: hidden; @@ -546,8 +536,6 @@ button { font-size: 12px; } -.chart-section-label, -.chart-field-type, .chart-activity-kind, .chart-more { color: #7e8792; @@ -555,28 +543,20 @@ button { line-height: 1.3; } -.chart-section-label, .chart-activity-kind { text-transform: uppercase; } .chart-state-section { width: min(300px, 100%); - margin-top: 11px; - padding-top: 8px; + margin-top: 8px; + padding-top: 7px; border-top: 1px solid #242a31; } -.chart-section-label { - margin-bottom: 4px; - letter-spacing: 0.06em; -} - -.chart-field-row, .chart-activity-row { display: grid; - min-height: 22px; - grid-template-columns: minmax(0, 1fr) auto; + min-height: 20px; gap: 10px; } @@ -584,13 +564,11 @@ button { grid-template-columns: auto minmax(0, 1fr); } -.chart-field-name, .chart-activity-name { color: #c4c9d0; font-size: 10px; } -.chart-field-type, .chart-activity-kind { flex: 0 1 auto; overflow: hidden; @@ -646,15 +624,15 @@ button { } .chart-more { - min-height: 18px; - padding-top: 3px; + min-height: 16px; + padding-top: 2px; } .chart-initial { position: absolute; border-radius: 50%; - background: #9ba3ae; - box-shadow: 0 0 0 3px rgb(155 163 174 / 8%); + background: #b0965d; + box-shadow: 0 0 0 2px rgb(176 150 93 / 10%); pointer-events: none; } @@ -705,7 +683,8 @@ button { } .chart-edge-initial .chart-edge-line { - stroke: #89929e; + stroke: #9f8a5c; + stroke-width: 1.2; } .chart-edge-hit { @@ -716,7 +695,8 @@ button { } #chart-arrow path, -#chart-direction path { +#chart-direction path, +#chart-initial-arrow path { fill: context-stroke; } @@ -1613,7 +1593,7 @@ button { gap: 12px; } -.input-field-identity, +.input-field-contract, .input-constraints { display: flex; min-width: 0; @@ -1622,6 +1602,12 @@ button { gap: 6px; } +.input-field-contract { + flex: 0 0 auto; + justify-content: flex-end; + margin-left: auto; +} + .input-label, .input-required, .input-optional { @@ -1681,6 +1667,18 @@ button { color: #d7a5a2; } +.value-shape-block { + margin: 0; + padding: 12px; + border: 1px solid var(--line-soft); + color: #b8c1ce; + background: var(--surface-raised); + font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-x: auto; + tab-size: 2; + white-space: pre; +} + .trace-topology { display: grid; gap: 10px; diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index 01c9787..108b78a 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -271,13 +271,12 @@ const renderContractField = ( if (omit.has(label)) return null const row = createElement("div", "input-contract-field") const heading = createElement("div", "input-field-heading") - const identity = createElement("div", "input-field-identity") - identity.append( - createElement("span", "input-label", label), + const contract = createElement("div", "input-field-contract") + contract.append( createElement("span", "input-field-type", inputType(field)), createElement("span", required ? "input-required" : "input-optional", required ? "required" : "optional") ) - heading.append(identity) + heading.append(createElement("span", "input-label", label), contract) row.append(heading) const constraints = inputConstraints(field) if (constraints.length > 0) { @@ -350,6 +349,55 @@ const contractSummary = (schema: InputSchema | null): string | null => { ) + (fields.length > 3 ? ` ยท +${fields.length - 3}` : "") } +type ValueShape = string | ReadonlyArray | { readonly [key: string]: ValueShape } + +const typePreview = (field: InputField): string => { + switch (field._tag) { + case "String": + return field.format === undefined ? "string" : `string (${field.format})` + case "Number": + return field.integer ? "integer" : "number" + case "Boolean": + return "boolean" + case "Enum": + return field.values.map(String).join(" | ") + case "Literal": + return String(field.value) + case "Object": + return "object" + case "Array": + return `${typePreview(field.item)}[]` + case "Union": + return [...new Set(field.alternatives.map(typePreview))].join(" | ") + case "Unsupported": + return "unknown" + } +} + +const fieldValueShape = (field: InputField): ValueShape => { + if (field._tag === "Object") { + return Object.fromEntries( + field.fields + .filter(({ key }) => key !== "_tag") + .map(({ key, required, field }) => [required ? key : `${key}?`, fieldValueShape(field)]) + ) + } + if (field._tag === "Array") return [fieldValueShape(field.item)] + return typePreview(field) +} + +export const valueShape = (schema: InputSchema): ValueShape => fieldValueShape(projectInputSchema(schema)) + +const renderValueShape = (title: string, schema: InputSchema): HTMLElement => { + const section = createElement("section", "inspector-section value-shape-section") + section.append(inspectionSection(title)) + const code = createElement("code", undefined, JSON.stringify(valueShape(schema), null, 2)) + const block = createElement("pre", "value-shape-block") + block.append(code) + section.append(block) + return section +} + const triggerName = (trigger: Trigger): string => { switch (trigger.type) { case "event": @@ -518,6 +566,13 @@ export const renderVisualizer = ( } inspectorContent.append(header) + if ( + inspection.state.valueSchema !== null && + contractSummary(inspection.state.valueSchema) !== null + ) { + inspectorContent.append(renderValueShape("Value", inspection.state.valueSchema)) + } + if (inspection.outgoing.length > 0) { const transitions = createElement("section", "inspector-section") transitions.append(inspectionSection("Transitions", inspection.outgoing.length)) diff --git a/packages/devtools/test/StaticSite.test.ts b/packages/devtools/test/StaticSite.test.ts index beb64e8..5ccc45c 100644 --- a/packages/devtools/test/StaticSite.test.ts +++ b/packages/devtools/test/StaticSite.test.ts @@ -22,6 +22,7 @@ const localExampleMachineIds = [ "parent-child-protocol", "planner-example", "required-parent-child", + "shared-terminal-routing", "transition-semantics" ] diff --git a/packages/devtools/test/internal/browser/MachineIndex.test.ts b/packages/devtools/test/internal/browser/MachineIndex.test.ts new file mode 100644 index 0000000..acde922 --- /dev/null +++ b/packages/devtools/test/internal/browser/MachineIndex.test.ts @@ -0,0 +1,13 @@ +import { assert, describe, it } from "@effect/vitest" +import { machineIndexScrollLeft } from "../../../src/internal/browser/machine-index.js" + +describe("Machine index", () => { + it("preserves the current horizontal position when the selected machine remains visible", () => { + assert.strictEqual(machineIndexScrollLeft(420, 640, 720, 160), 420) + }) + + it("moves only enough to reveal a selected machine outside the viewport", () => { + assert.strictEqual(machineIndexScrollLeft(420, 640, 320, 160), 320) + assert.strictEqual(machineIndexScrollLeft(420, 640, 1_020, 160), 540) + }) +}) diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts index aa0c8f8..02f640c 100644 --- a/packages/devtools/test/internal/browser/StaticChart.test.ts +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -1,7 +1,11 @@ import { assert, describe, it } from "@effect/vitest" import * as Effect from "effect/Effect" +import type { ELK as ElkApi, ELKConstructorArguments, ElkNode } from "elkjs/lib/elk-api.js" +import ELKBundle from "elkjs/lib/elk.bundled.js" import { makeChartLayoutPolicy } from "../../../src/internal/browser/chart-layout-policy.js" import { + chartEdgeLabelSpacing, + chartRouteLength, chartSelfLoopMinimumClearance, layoutChart, layoutChartWith, @@ -28,10 +32,13 @@ import { parentProtocolMachine, requiredParentChildMachine } from "../../../src/internal/browser/protocol-events-example.js" +import { sharedTerminalRoutingMachine } from "../../../src/internal/browser/shared-terminal-routing-example.js" import { transitionSemanticsMachine } from "../../../src/internal/browser/transition-semantics-example.js" import * as MachineDocument from "../../../src/MachineDocument.js" describe("Static chart", () => { + const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi + it("rounds orthogonal bends and places direction cues on long routes", () => { assert.strictEqual( chartEdgePathData([ @@ -55,7 +62,7 @@ describe("Static chart", () => { assert.strictEqual(chartDirectionCue([{ x: 0, y: 0 }, { x: 200, y: 0 }]), null) }) - it("derives a left-to-right policy from initial reachability", () => { + it("derives a top-to-bottom policy from initial reachability", () => { const node = (path: string, type: ChartNode["type"] = "atomic"): ChartNode => ({ path, label: path, @@ -64,7 +71,6 @@ describe("Static chart", () => { children: [], active: false, initial: path === "Idle", - fields: [], activities: [] }) const model: ChartModel = { @@ -123,44 +129,99 @@ describe("Static chart", () => { staticPath: true, rank: 0, order: 0, - layerConstraint: null + layerConstraint: "FIRST" }) 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", - targetSide: "WEST" + sourceSide: "SOUTH", + targetSide: "NORTH" }) assert.deepStrictEqual(policy.edge(model.edges[1]!), { direction: "backward", - sourceSide: "WEST", - targetSide: "EAST" + sourceSide: "NORTH", + targetSide: "SOUTH" }) assert.deepStrictEqual(policy.edge(model.edges[2]!), { direction: "self", - sourceSide: "SOUTH", - targetSide: "SOUTH" + sourceSide: "EAST", + targetSide: "EAST" + }) + }) + + it("uses side lanes for transitions crossing a parent boundary", () => { + const node = (path: string, parent: string | null, children: ReadonlyArray): ChartNode => ({ + path, + label: path, + type: children.length === 0 ? "atomic" : "compound", + parent, + children, + active: false, + initial: path === "Workflow" || path === "Workflow.Idle", + activities: [] + }) + const edges: ChartModel["edges"] = [ + { + id: "enter", + transitionId: "enter", + branchIds: ["enter"], + kind: "target", + source: "Workflow", + target: "Workflow.Idle", + label: "Enter", + trigger: { type: "event", event: "Enter" }, + activityKind: null, + reenter: false, + acceptance: "required" + }, + { + id: "leave", + transitionId: "leave", + branchIds: ["leave"], + kind: "target", + source: "Workflow.Idle", + target: "Workflow", + label: "Leave", + trigger: { type: "event", event: "Leave" }, + activityKind: null, + reenter: false, + acceptance: "required" + } + ] + const policy = makeChartLayoutPolicy({ + machineId: "hierarchy-policy", + roots: ["Workflow"], + nodes: [ + node("Workflow", null, ["Workflow.Idle"]), + node("Workflow.Idle", "Workflow", []) + ], + edges, + runtimeTargets: [], + initials: [ + { id: "initial:Workflow", target: "Workflow", parent: null }, + { id: "initial:Workflow.Idle", target: "Workflow.Idle", parent: "Workflow" } + ] + }) + + assert.deepStrictEqual(policy.edge(edges[0]!), { + direction: "forward", + sourceSide: "EAST", + targetSide: "EAST" + }) + assert.deepStrictEqual(policy.edge(edges[1]!), { + direction: "backward", + sourceSide: "WEST", + targetSide: "WEST" }) }) - it("projects state fields, invocation metadata, and transition branches", () => { + it("projects invocation metadata and transition branches without state value fields", () => { const document = MachineDocument.make(plannerMachine) const model = makeChartModel(document) - const idle = model.nodes.find((node) => node.path === "Idle") const working = model.nodes.find((node) => node.path === "Working") - assert.deepStrictEqual(idle?.fields, [{ - key: "owner", - label: "owner", - type: "string", - required: true - }]) - assert.deepStrictEqual(working?.fields.map(({ key, type }) => ({ key, type })), [ - { key: "owner", type: "string" }, - { key: "job", type: "string" } - ]) assert.deepStrictEqual(working?.activities.map(({ kind, label }) => ({ kind, label })), [ { kind: "effect", label: "monitor-job" } ]) @@ -197,16 +258,79 @@ describe("Static chart", () => { ) }) - it("keeps parent-to-child transitions off the initial-entry lane", async () => { + it("keeps value-bearing states compact while reserving room for invocations", async () => { + const layout = await Effect.runPromise(layoutChart(makeChartModel(MachineDocument.make(plannerMachine)))) + const idle = layout.nodes.find((node) => node.node.path === "Idle") + const working = layout.nodes.find((node) => node.node.path === "Working") + + assert.strictEqual(idle?.height, 52) + assert.isAtMost(idle?.width ?? Number.POSITIVE_INFINITY, 160) + assert.isAbove(working?.height ?? 0, idle?.height ?? Number.POSITIVE_INFINITY) + assert.isAtMost( + (idle?.y ?? Number.POSITIVE_INFINITY) + (idle?.height ?? 0), + working?.y ?? Number.NEGATIVE_INFINITY + ) + }) + + it("places a compact initial marker entering the target from above", async () => { const model = makeChartModel(MachineDocument.make(invokeOutcomesMachine)) const layout = await Effect.runPromise(layoutChart(model)) const initial = layout.edges.find((edge) => edge.kind === "initial" && edge.initial.target === "Gallery.Choose") - const reset = layout.edges.find((edge) => edge.kind === "transition" && edge.edge.label === "Reset") - if (initial?.kind !== "initial" || reset?.kind !== "transition") { - assert.fail("Expected the Choose initial entry and Reset transition") + const marker = layout.initials.find((entry) => entry.initial.target === "Gallery.Choose") + const target = layout.nodes.find((node) => node.node.path === "Gallery.Choose") + if (initial?.kind !== "initial" || marker === undefined || target === undefined) { + assert.fail("Expected the Choose initial marker") + } + + assert.isAtLeast(marker.x, target.x) + assert.isBelow(marker.x + marker.width, target.x + target.width) + assert.isBelow(marker.y + marker.height, target.y) + assert.deepStrictEqual(initial.points.at(-1), { x: marker.x + marker.width / 2, y: target.y }) + assert.isBelow( + initial.points.slice(1).reduce((length, point, index) => { + const previous = initial.points[index]! + return length + Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y) + }, 0), + 30 + ) + }) + + it("attaches invoke failures to the Failed state instead of a nearby route", async () => { + const model = makeChartModel(MachineDocument.make(invokeOutcomesMachine)) + const layout = await Effect.runPromise(layoutChart(model)) + const failed = layout.nodes.find(({ node }) => node.path === "Failed") + const failures = layout.edges.filter((edge) => + edge.kind === "transition" && edge.edge.trigger.type === "invoke" && edge.edge.target === "Failed" + ) + if (failed === undefined) assert.fail("Expected the Failed state") + + assert.lengthOf(failures, 3) + for (const failure of failures) { + const end = failure.points.at(-1)! + assert.strictEqual(end.y, failed.y) + assert.isAtLeast(end.x, failed.x) + assert.isAtMost(end.x, failed.x + failed.width) + } + }) + + it("leaves an atomic source through its boundary before turning", async () => { + const model = makeChartModel(MachineDocument.make(invokeOutcomesMachine)) + const layout = await Effect.runPromise(layoutChart(model)) + const failed = layout.nodes.find(({ node }) => node.path === "Failed") + const reset = layout.edges.find((edge) => + edge.kind === "transition" && edge.edge.source === "Failed" && edge.edge.label === "Reset" + ) + if (failed === undefined || reset?.kind !== "transition") { + assert.fail("Expected the Failed reset transition") } - assert.notStrictEqual(reset.points.at(-1)?.y, initial.points.at(-1)?.y) + const start = reset.points[0]! + const next = reset.points[1]! + assert.strictEqual(start.y, failed.y) + assert.isAtLeast(start.x, failed.x) + assert.isAtMost(start.x, failed.x + failed.width) + assert.strictEqual(next.x, start.x) + assert.isBelow(next.y, start.y) }) it("keeps transition labels attached to their ELK routes", async () => { @@ -251,8 +375,26 @@ describe("Static chart", () => { })) 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) + assert.isBelow(initial.y + initial.height, target?.y ?? Number.POSITIVE_INFINITY) + assert.isAtLeast(initial.x, target?.x ?? Number.NEGATIVE_INFINITY) + assert.isBelow(initial.x + initial.width, (target?.x ?? 0) + (target?.width ?? 0)) + } + + const online = layout.nodes.find(({ node }) => node.path === "application.connection.online") + const offline = layout.nodes.find(({ node }) => node.path === "application.connection.offline") + const disconnect = layout.edges.find((edge) => edge.kind === "transition" && edge.edge.label === "Disconnect") + if (online === undefined || offline === undefined || disconnect?.kind !== "transition") { + assert.fail("Expected the connection flow") } + assert.isAtMost(offline.y - online.y - online.height, 180) + assert.isTrue( + disconnect.points.slice(1).some((point, index) => { + const previous = disconnect.points[index]! + return previous.x === point.x && disconnect.label.x === point.x && + disconnect.label.y >= Math.min(previous.y, point.y) && + disconnect.label.y <= Math.max(previous.y, point.y) + }) + ) }) it("lays out targetless transitions as self-loops", async () => { @@ -290,16 +432,16 @@ describe("Static chart", () => { chartSelfLoopMinimumClearance ) assert.isAbove( - laidOut.label.y - laidOut.labelHeight / 2, - Math.max(...laidOut.points.map(({ y }) => y)) + laidOut.label.x - laidOut.labelWidth / 2, + Math.max(...laidOut.points.map(({ x }) => x)) ) - assert.isAtMost(laidOut.label.y + laidOut.labelHeight / 2, layout.height) + assert.isAtMost(laidOut.label.x + laidOut.labelWidth / 2, layout.width) 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) + assert.isAtMost(laidOut.label.x + laidOut.labelWidth / 2, parent.x + parent.width) } } }) @@ -330,7 +472,7 @@ describe("Static chart", () => { assert.strictEqual(transitionEdges.length, model.edges.length) assert.strictEqual(updates.length, 3) assert.deepStrictEqual( - new Set(updates.map(({ points }) => Math.max(...points.map(({ y }) => y)))).size, + new Set(updates.map(({ points }) => Math.max(...points.map(({ x }) => x)))).size, 3 ) assert.isTrue( @@ -373,6 +515,9 @@ describe("Static chart", () => { assert.isAtLeast(parentTransition.points.length, 2) assert.isAtLeast(externalFailure.points.length, 2) + assert.strictEqual(parentTransition.points[0]?.y, source.y + source.headerHeight) + assert.isAtLeast(Math.min(...externalFailure.points.map(({ y }) => y)), target.y) + assert.isBelow(chartRouteLength(externalFailure.points), 500) assert.deepStrictEqual(validateChartLayout(model, layout).issues, []) assert.deepStrictEqual( repeated.edges.map((edge) => ({ @@ -389,15 +534,57 @@ describe("Static chart", () => { it("tries deterministic spacing profiles before reporting an unsafe layout", async () => { const model = makeChartModel(MachineDocument.make(layoutResilienceMachine)) const attempts: Array = [] - const failure = await Effect.runPromise(Effect.flip(layoutChartWith(model, async (_graph, constraints) => { + const labelSpacings: Array = [] + const failure = await Effect.runPromise(Effect.flip(layoutChartWith(model, async (graph, constraints) => { attempts.push(constraints) + labelSpacings.push(graph.layoutOptions?.["elk.spacing.edgeLabel"]) throw new Error(`${constraints} layout failed`) }))) assert.deepStrictEqual(attempts, ["fixed", "fixed", "fixed", "relaxed", "relaxed"]) + assert.deepStrictEqual(labelSpacings, Array.from({ length: 5 }, () => String(chartEdgeLabelSpacing))) assert.include(failure.message, "roomy-relaxed: relaxed layout failed") }) + it("falls back to a chart with only label-to-route warnings after exhausting clean layouts", async () => { + const model = makeChartModel(MachineDocument.make(sharedTerminalRoutingMachine)) + const elk = new ELK() + let cachedLayout: Promise | undefined + const engine = (graph: ElkNode): Promise => cachedLayout ??= elk.layout(graph) + let attempts = 0 + const layout = await Effect.runPromise(layoutChartWith( + model, + async (graph) => { + attempts++ + return engine(graph) + }, + (_model, candidate) => ({ + valid: false, + issues: [{ code: "label-route-overlap", edgeId: model.edges[0]!.id, relatedId: model.edges[1]!.id }], + crossings: 0, + routeLength: candidate.edges.reduce((total, edge) => total + chartRouteLength(edge.points), 0) + }) + )) + + assert.strictEqual(attempts, 5) + assert.strictEqual(layout.edges.length, model.edges.length + model.initials.length) + + const failure = await Effect.runPromise(Effect.flip(layoutChartWith( + model, + engine, + () => ({ + valid: false, + issues: [ + { code: "label-route-overlap", edgeId: model.edges[0]!.id, relatedId: model.edges[1]!.id }, + { code: "route-overlap", edgeId: model.edges[0]!.id, relatedId: model.edges[1]!.id } + ], + crossings: 0, + routeLength: 0 + }) + ))) + assert.include(failure.message, "route-overlap") + }) + it("keeps the example topology corpus deterministic and free of hard geometry violations", async () => { const corpus = [ ["nested workflow", makeChartModel(MachineDocument.make(machine, { snapshot }))], @@ -407,6 +594,7 @@ describe("Static chart", () => { ["invoke outcomes", makeChartModel(MachineDocument.make(invokeOutcomesMachine))], ["layout resilience", makeChartModel(MachineDocument.make(layoutResilienceMachine))], ["hierarchy routing", makeChartModel(MachineDocument.make(hierarchyRoutingMachine))], + ["shared terminal routing", makeChartModel(MachineDocument.make(sharedTerminalRoutingMachine))], ["required parent protocol", makeChartModel(MachineDocument.make(requiredParentChildMachine))], ["parent protocol", makeChartModel(MachineDocument.make(parentProtocolMachine))], ["optional parent", makeChartModel(MachineDocument.make(optionalParentMachine))] @@ -431,20 +619,20 @@ describe("Static chart", () => { } }) - it("stacks parallel regions as vertical lanes", async () => { + it("places parallel regions side by side while each region flows downward", 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) + .sort((left, right) => left.x - right.x) 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) + assert.isAtMost(regions[0]!.x + regions[0]!.width, regions[1]!.x) }) it("groups states without a static path from the initial state", async () => { @@ -456,7 +644,6 @@ describe("Static chart", () => { children: [], active: false, initial, - fields: [], activities: [] }) const model: ChartModel = { @@ -486,6 +673,19 @@ describe("Static chart", () => { assert.strictEqual(policy.node("Disabled").staticPath, false) assert.isTrue(layout.regions.some(({ nodePaths }) => nodePaths.includes("Disabled"))) + + const recent = layout.nodes.find(({ node }) => node.path === "Workspace.recent") + const resume = layout.edges.find((edge) => edge.kind === "transition" && edge.edge.label === "ResumeShallow") + if (recent === undefined || resume?.kind !== "transition") assert.fail("Expected the shallow history transition") + const approach = resume.points.at(-2)! + const end = resume.points.at(-1)! + assert.isTrue(approach.x === end.x || approach.y === end.y) + assert.isTrue( + (end.x === recent.x || end.x === recent.x + recent.width) && + end.y >= recent.y && end.y <= recent.y + recent.height || + (end.y === recent.y || end.y === recent.y + recent.height) && + end.x >= recent.x && end.x <= recent.x + recent.width + ) }) it("lays out runtime-resolved targets as explicit stubs", async () => { diff --git a/packages/devtools/test/internal/browser/VisualizerApp.test.ts b/packages/devtools/test/internal/browser/VisualizerApp.test.ts new file mode 100644 index 0000000..fcd1018 --- /dev/null +++ b/packages/devtools/test/internal/browser/VisualizerApp.test.ts @@ -0,0 +1,35 @@ +import { assert, describe, it } from "@effect/vitest" +import { valueShape } from "../../../src/internal/browser/visualizer-app.js" + +describe("Visualizer app", () => { + it("reduces captured state schemas to a compact JSON-shaped type preview", () => { + assert.deepStrictEqual( + valueShape({ + dialect: "draft-2020-12", + schema: { $ref: "#/$defs/Idle" }, + definitions: { + Idle: { + type: "object", + properties: { + _tag: { type: "string", enum: ["Idle"] }, + owner: { type: "string" }, + attempts: { type: "integer" }, + mode: { type: "string", enum: ["login", "signup"] }, + note: { type: "string" }, + tags: { type: "array", items: { type: "string" } } + }, + required: ["_tag", "owner", "attempts", "mode", "tags"], + additionalProperties: false + } + } + }), + { + owner: "string", + attempts: "integer", + mode: "login | signup", + "note?": "string", + tags: ["string"] + } + ) + }) +})