Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clear-charts-signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/effect-machine-devtools": patch
---

Make statecharts easier to scan with compact graphical badges for automatic transitions, invocation outcomes, stream updates, snapshots, choices, and branch groups.

Failure transitions now use a distinct red treatment, while state cards, activity colors, and transition labels use a colorblind-friendly palette with stronger foreground contrast.
28 changes: 21 additions & 7 deletions packages/devtools/src/internal/browser/chart-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,24 @@ const portsByState = (
return ports
}

const labelMetric = (label: string): { readonly width: number; readonly height: number } => ({
width: Math.min(230, Math.max(72, label.length * 7 + 20)),
height: 26
})
const badgeWidth = (badge: ChartEdge["badges"][number]): number => badge.type === "branches" ? 21 : 11

const labelMetric = (edge: ChartEdge): { readonly width: number; readonly height: number } => {
const badgesWidth = edge.badges.reduce((width, badge) => width + badgeWidth(badge), 0) +
Math.max(0, edge.badges.length - 1) * 3
if (edge.label.length === 0) {
return {
width: Math.max(20, badgesWidth + 4),
height: 20
}
}
const bodyWidth = Math.min(226, Math.max(52, edge.label.length * 7 + 20))
const hasCornerBadge = edge.badges.length > 0
return {
width: bodyWidth + (hasCornerBadge ? 4 : 0),
height: 26 + (hasCornerBadge ? 4 : 0)
}
}

const makeGraph = (
model: ChartModel,
Expand Down Expand Up @@ -443,14 +457,14 @@ const makeGraph = (
children: children(null),
edges: [
...model.edges.map((edge): ElkExtendedEdge => {
const label = labelMetric(edge.label)
const label = labelMetric(edge)
const edgeLayout = policy.edge(edge)
const runtimeTarget = edge.kind === "runtime" ? runtimeByEdgeId.get(edge.id) : undefined
return {
id: edge.id,
sources: [sourcePortId(edge)],
targets: [runtimeTarget === undefined ? targetPortId(edge) : runtimeTargetPortId(runtimeTarget)],
labels: [{ text: edge.label, width: label.width, height: label.height }],
labels: [{ text: edge.accessibleLabel, width: label.width, height: label.height }],
layoutOptions: {
"elk.layered.priority.direction": edgeLayout.direction === "forward" ? "10" : "1",
"elk.layered.priority.shortness": "5",
Expand Down Expand Up @@ -1076,7 +1090,7 @@ const collectLayout = (
nodesByPath,
nodes
)
const metric = labelMetric(chartEdge.label)
const metric = labelMetric(chartEdge)
const label = edge.labels?.[0]
const labelWidth = label?.width ?? metric.width
const labelHeight = label?.height ?? metric.height
Expand Down
52 changes: 51 additions & 1 deletion packages/devtools/src/internal/browser/chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ export interface ChartNode {
readonly activities: ReadonlyArray<ChartActivity>
}

export type ChartEdgeBadge =
| { readonly type: "always" }
| { readonly type: "completion" }
| { readonly type: "choice" }
| { readonly type: "failure" }
| { readonly type: "element" }
| { readonly type: "snapshot" }
| { readonly type: "branches"; readonly count: number }

export interface ChartEdge {
readonly id: string
readonly transitionId: string
Expand All @@ -32,6 +41,8 @@ export interface ChartEdge {
readonly source: string
readonly target: string | null
readonly label: string
readonly accessibleLabel: string
readonly badges: ReadonlyArray<ChartEdgeBadge>
readonly trigger: VisualizationTransition["trigger"]
readonly activityKind: ChartActivity["kind"] | null
readonly reenter: boolean
Expand Down Expand Up @@ -78,6 +89,43 @@ const transitionLabel = (transition: VisualizationTransition, branch: Visualizat
return branch.type === "branch" ? `${trigger} · ${branch.title}` : trigger
}

interface TriggerPresentation {
readonly label: string
readonly badges: ReadonlyArray<ChartEdgeBadge>
}

const triggerPresentation = (transition: VisualizationTransition): TriggerPresentation => {
switch (transition.trigger.type) {
case "event":
return { label: transition.trigger.event, badges: [] }
case "always":
return { label: "", badges: [{ type: "always" }] }
case "done":
return { label: "", badges: [{ type: "completion" }] }
case "choice":
return { label: "", badges: [{ type: "choice" }] }
case "invoke":
return {
label: transition.trigger.id,
badges: [{ type: transition.trigger.outcome === "done" ? "completion" : transition.trigger.outcome }]
}
}
}

const visibleLabel = (transition: VisualizationTransition, branches: ReadonlyArray<VisualizationBranch>): string => {
const presentation = triggerPresentation(transition)
if (branches.length !== 1 || branches[0]?.type !== "branch") return presentation.label
return [presentation.label, branches[0].title].filter((part) => part.length > 0).join(" · ")
}

const labelBadges = (
transition: VisualizationTransition,
branches: ReadonlyArray<VisualizationBranch>
): ReadonlyArray<ChartEdgeBadge> => [
...triggerPresentation(transition).badges,
...(branches.length > 1 ? [{ type: "branches" as const, count: branches.length }] : [])
]

interface EdgeGroup {
readonly kind: ChartEdge["kind"]
readonly target: string | null
Expand Down Expand Up @@ -146,9 +194,11 @@ export const makeChartModel = (document: VisualizationDocument): ChartModel => {
kind,
source: transition.source,
target,
label: branches.length === 1
label: visibleLabel(transition, branches),
accessibleLabel: branches.length === 1
? transitionLabel(transition, branches[0]!)
: `${triggerLabel(transition)} · ${branches.length} branches`,
badges: labelBadges(transition, branches),
trigger: transition.trigger,
activityKind: transition.trigger.type === "invoke"
? activitiesBySource.get(transition.source)?.get(transition.trigger.id) ?? null
Expand Down
71 changes: 67 additions & 4 deletions packages/devtools/src/internal/browser/chart-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
layoutChart,
maxVisibleActivities
} from "./chart-layout.js"
import { makeChartModel } from "./chart-model.js"
import { type ChartEdgeBadge, makeChartModel } from "./chart-model.js"

export interface ChartHandlers {
readonly selectState: (path: string, anchor: ChartInteractionAnchor) => void
Expand Down Expand Up @@ -109,6 +109,57 @@ const svgElement = <Tag extends keyof SVGElementTagNameMap>(
return node
}

const badgeIcon = (badge: ChartEdgeBadge): SVGSVGElement => {
const icon = svgElement("svg", "chart-edge-badge-icon")
icon.setAttribute("viewBox", "0 0 14 14")
icon.setAttribute("aria-hidden", "true")
const path = svgElement("path")
switch (badge.type) {
case "completion":
path.setAttribute("d", "M 3 7.2 L 5.7 9.7 L 11 4.2")
break
case "failure":
path.setAttribute("d", "M 4 4 L 10 10 M 10 4 L 4 10")
break
case "element":
path.setAttribute("d", "M 2 7 H 4.2 L 5.7 3.8 L 8.1 10.2 L 9.7 7 H 12")
break
case "snapshot": {
const ring = svgElement("circle")
ring.setAttribute("cx", "7")
ring.setAttribute("cy", "7")
ring.setAttribute("r", "3.5")
const point = svgElement("circle")
point.setAttribute("cx", "7")
point.setAttribute("cy", "7")
point.setAttribute("r", "1.2")
point.classList.add("chart-edge-badge-icon-fill")
icon.append(ring, point)
return icon
}
case "always":
path.setAttribute(
"d",
"M 2 7 C 3.3 4.5 4.9 4.5 7 7 C 9.1 9.5 10.7 9.5 12 7 C 10.7 4.5 9.1 4.5 7 7 C 4.9 9.5 3.3 9.5 2 7"
)
break
case "choice":
case "branches":
path.setAttribute("d", "M 7 11 V 7.5 M 7 7.5 L 3.5 4 M 7 7.5 L 10.5 4")
break
}
icon.append(path)
return icon
}

const edgeBadge = (badge: ChartEdgeBadge): HTMLSpanElement => {
const node = element("span", `chart-edge-badge chart-edge-badge-${badge.type}`)
node.setAttribute("aria-hidden", "true")
node.append(badgeIcon(badge))
if (badge.type === "branches") node.append(element("span", "chart-edge-badge-count", String(badge.count)))
return node
}

const position = (
target: HTMLElement,
bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
Expand Down Expand Up @@ -425,13 +476,15 @@ const render = (
for (const laidOut of layout.edges) {
const parentChild = laidOut.kind === "transition" && laidOut.edge.target !== null &&
parentByState.get(laidOut.edge.target) === laidOut.edge.source
const failure = laidOut.kind === "transition" &&
laidOut.edge.badges.some((badge) => badge.type === "failure")
const group = svgElement(
"g",
`chart-edge-group chart-edge-${laidOut.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}`
}${parentChild ? " chart-edge-parent-child" : ""}`
}${parentChild ? " chart-edge-parent-child" : ""}${failure ? " chart-edge-failure" : ""}`
: ""
}`
)
Expand Down Expand Up @@ -476,11 +529,21 @@ const render = (
"button",
`chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}${
laidOut.edge.activityKind === null ? "" : ` chart-edge-activity-${laidOut.edge.activityKind}`
}${parentChild ? " chart-edge-label-parent-child" : ""}`,
laidOut.edge.label
}${parentChild ? " chart-edge-label-parent-child" : ""}${failure ? " chart-edge-label-failure" : ""}${
laidOut.edge.label.length === 0 ? " chart-edge-label-icon-only" : ""
}${laidOut.edge.label.length > 0 && laidOut.edge.badges.length > 0 ? " chart-edge-label-corner-badge" : ""}`
)
label.type = "button"
label.setAttribute("aria-label", laidOut.edge.accessibleLabel)
if (parentChild) label.title = "Transition declared by the parent state"
if (laidOut.edge.label.length > 0) {
label.append(element("span", "chart-edge-label-text", laidOut.edge.label))
}
if (laidOut.edge.badges.length > 0) {
const badges = element("span", "chart-edge-badges")
badges.append(...laidOut.edge.badges.map(edgeBadge))
label.append(badges)
}
label.dataset.transitionId = laidOut.edge.transitionId
position(label, {
x: laidOut.label.x - laidOut.labelWidth / 2,
Expand Down
Loading