Skip to content

Commit f94e507

Browse files
fahchenclaude
andcommitted
fix(dashboard): NetDiagram polish — rounded edges, inline tokens, code colset, matched borders, uncapped transition width, sticky-enabled replay
Six readability tweaks on top of 227fb9d: 1. Rounded polyline corners on orthogonal edges OrthogonalEdgeImpl delegates to new roundedPolylinePath that emits a quadratic Q arc at each bend (radius 8). Per-corner cap is min(radius, len(prev)/2, len(next)/2) so short legs don't overshoot. Two-point edges short-circuit to a plain polyline. 2. Token count inline next to place name Dropped the corner Badge overlay. Token count now renders on the external label line, right of the place name, in text-cf-accent bold tabular-nums. testid place-token-badge-${name} preserved on the inline span. 3. Colour set name styled as code Wrapped in a <code> with font-mono text-[10px] rounded border border-cf-border bg-cf-canvas px-1. Visually reads as an identifier, not body text. Kumo's <Code> primitive is deprecated (v2.0) + tuned for code snippets; custom <code> is the right shape here. 4. Place + transition borders match edge stroke Place circle: flat 1.5px borderWidth, borderColor toggles cf-border-strong / cf-accent on hasTokens (was bumped to 2 on tokens). Transition border flipped to cf-border-strong to match the edge family. Glow/pulse boxShadow untouched. 5. Transition width uncapped Dropped TRANSITION_MAX_W. computeTransitionWidth now Math.max(MIN_W, maxLen * 7 + 20). Single shared width still applied to all transitions for a clean ELK grid. 6. Sticky enabled edges across replay scrubbing EnactmentDetailPage derives currentlyEnabledEdgeIds from diagram.transitions[].enabled_count > 0 every tick. During replay (replayState !== null) the page maintains stickyEnabledEdgeIds: Set, unioning the current set on every step so an edge that was enabled at v=5 stays highlighted when the operator scrubs to v=6 even if its underlying transition is no longer enabled there. Reset to empty when replayState clears (exit replay). Live mode passes only currentlyEnabledEdgeIds; replay passes the union. NetDiagram gains an optional enabledEdgeIds prop; buildGraph falls back to auto-derive when omitted (keeps unit tests green). 161/161 vitest + typecheck + build green; 176/176 mix + dialyzer 0; lib/coloured_flow byte-identical. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f3a84a7 commit f94e507

2 files changed

Lines changed: 133 additions & 35 deletions

File tree

dashboard/ui/src/components/NetDiagram.tsx

Lines changed: 85 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from "@xyflow/react"
2525
import ELK, { type ElkNode, type ElkExtendedEdge, type ElkPoint } from "elkjs/lib/elk.bundled.js"
2626
import { ArrowsCounterClockwiseIcon } from "@phosphor-icons/react"
27-
import { Badge, Surface } from "@cloudflare/kumo"
27+
import { Surface } from "@cloudflare/kumo"
2828

2929
type NetDiagramPayload = ColouredFlowDashboardWeb.Views.NetDiagram
3030
type DiagramPlace = ColouredFlowDashboardWeb.Views.NetDiagramPlace
@@ -39,6 +39,11 @@ interface NetDiagramProps {
3939
onSelectTransition?: (name: string) => void
4040
firingEdgeIds?: ReadonlySet<string>
4141
firingDurationMs?: number
42+
// Optional override for the enabled-arc accent. When supplied, only edges
43+
// whose id is in the set get the cf-edge-enabled style. When omitted,
44+
// buildGraph derives the set from `transition.enabled_count > 0` (legacy
45+
// path used by unit tests and live mode without replay sticky-state).
46+
enabledEdgeIds?: ReadonlySet<string>
4247
}
4348

4449
const DEFAULT_FIRING_DURATION_MS = 600
@@ -58,19 +63,23 @@ const PLACE_W = 48
5863
const PLACE_H = PLACE_CIRCLE + PLACE_LABEL_GAP
5964
const TRANSITION_H = 28
6065
const TRANSITION_MIN_W = 56
61-
const TRANSITION_MAX_W = 200
6266
const TRANSITION_CHAR_PX = 7
6367
const TRANSITION_PAD_PX = 20
6468

69+
// No upper clamp: transition names in this codebase are short enough that an
70+
// explicit cap risked truncating a legitimate label. Single shared width is
71+
// still applied to every transition node so the grid stays consistent.
6572
function computeTransitionWidth(transitions: ReadonlyArray<DiagramTransition>): number {
6673
let maxLen = 0
6774
for (const t of transitions) {
6875
if (t.name.length > maxLen) maxLen = t.name.length
6976
}
7077
const raw = maxLen * TRANSITION_CHAR_PX + TRANSITION_PAD_PX
71-
return Math.max(TRANSITION_MIN_W, Math.min(TRANSITION_MAX_W, raw))
78+
return Math.max(TRANSITION_MIN_W, raw)
7279
}
7380

81+
const EDGE_CORNER_RADIUS = 8
82+
7483
type PlaceNodeData = DiagramPlace & Record<string, unknown>
7584
type TransitionNodeData = DiagramTransition & {
7685
enactmentState: EnactmentState
@@ -118,13 +127,22 @@ export default function NetDiagram({
118127
enactmentState = "running",
119128
onSelectTransition,
120129
firingEdgeIds = EMPTY_FIRING_SET,
121-
firingDurationMs = DEFAULT_FIRING_DURATION_MS
130+
firingDurationMs = DEFAULT_FIRING_DURATION_MS,
131+
enabledEdgeIds
122132
}: NetDiagramProps) {
123133
const layout = useElkLayout(diagram)
124134

125135
const { nodes, edges, isEmpty } = useMemo(
126-
() => buildGraph(diagram, enactmentState, firingEdgeIds, firingDurationMs, layout),
127-
[diagram, layout, enactmentState, firingEdgeIds, firingDurationMs]
136+
() =>
137+
buildGraph(
138+
diagram,
139+
enactmentState,
140+
firingEdgeIds,
141+
firingDurationMs,
142+
layout,
143+
enabledEdgeIds
144+
),
145+
[diagram, layout, enactmentState, firingEdgeIds, firingDurationMs, enabledEdgeIds]
128146
)
129147

130148
const handleNodeClick = useCallback(
@@ -329,7 +347,8 @@ export function buildGraph(
329347
enactmentState: EnactmentState,
330348
firingEdgeIds: ReadonlySet<string> = EMPTY_FIRING_SET,
331349
firingDurationMs: number = DEFAULT_FIRING_DURATION_MS,
332-
layout: Layout = EMPTY_LAYOUT
350+
layout: Layout = EMPTY_LAYOUT,
351+
enabledEdgeIdsOverride?: ReadonlySet<string>
333352
): { nodes: Array<PlaceNode | TransitionNode>; edges: Edge[]; isEmpty: boolean } {
334353
const places = diagram?.places ?? []
335354
const transitions = diagram?.transitions ?? []
@@ -382,8 +401,9 @@ export function buildGraph(
382401
const isFiring = firingEdgeIds.has(id)
383402
const isEnabledInput =
384403
!isFiring &&
385-
arc.orientation === "p_to_t" &&
386-
enabledTransitions.has(arc.transition)
404+
(enabledEdgeIdsOverride
405+
? enabledEdgeIdsOverride.has(id)
406+
: arc.orientation === "p_to_t" && enabledTransitions.has(arc.transition))
387407

388408
let style: CSSProperties
389409
let markerEnd = DEFAULT_MARKER
@@ -444,10 +464,40 @@ const transitionId = (name: string) => `t:${name}`
444464
function OrthogonalEdgeImpl({ data, markerEnd, style }: EdgeProps) {
445465
const points = (data as OrthoEdgeData | undefined)?.points
446466
if (!points || points.length < 2) return null
447-
const d = points
448-
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
449-
.join(" ")
450-
return <BaseEdge path={d} markerEnd={markerEnd} style={style} />
467+
return <BaseEdge path={roundedPolylinePath(points, EDGE_CORNER_RADIUS)} markerEnd={markerEnd} style={style} />
468+
}
469+
470+
// Build an SVG path from a polyline, replacing each interior 90° corner with
471+
// a quadratic-bezier arc. The corner radius is capped at half of the shorter
472+
// adjacent segment so very-short legs do not overshoot the bend.
473+
function roundedPolylinePath(points: ReadonlyArray<ElkPoint>, radius: number): string {
474+
if (points.length < 2) return ""
475+
if (points.length === 2 || radius <= 0) {
476+
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ")
477+
}
478+
let d = `M ${points[0].x} ${points[0].y}`
479+
for (let i = 1; i < points.length - 1; i++) {
480+
const prev = points[i - 1]
481+
const curr = points[i]
482+
const next = points[i + 1]
483+
const len1 = Math.hypot(curr.x - prev.x, curr.y - prev.y)
484+
const len2 = Math.hypot(next.x - curr.x, next.y - curr.y)
485+
const r = Math.max(0, Math.min(radius, len1 / 2, len2 / 2))
486+
if (r === 0) {
487+
d += ` L ${curr.x} ${curr.y}`
488+
continue
489+
}
490+
const t1 = (len1 - r) / len1
491+
const ax = prev.x + (curr.x - prev.x) * t1
492+
const ay = prev.y + (curr.y - prev.y) * t1
493+
const t2 = r / len2
494+
const bx = curr.x + (next.x - curr.x) * t2
495+
const by = curr.y + (next.y - curr.y) * t2
496+
d += ` L ${ax} ${ay} Q ${curr.x} ${curr.y} ${bx} ${by}`
497+
}
498+
const last = points[points.length - 1]
499+
d += ` L ${last.x} ${last.y}`
500+
return d
451501
}
452502

453503
const OrthogonalEdge = memo(OrthogonalEdgeImpl)
@@ -475,10 +525,13 @@ function PlaceNodeViewImpl({ data }: NodeProps<PlaceNode>) {
475525
<Surface
476526
as="div"
477527
title={tooltip}
478-
className="flex h-full w-full items-center justify-center rounded-full border bg-cf-surface shadow-sm"
528+
className="flex h-full w-full items-center justify-center rounded-full bg-cf-surface shadow-sm"
479529
style={{
480-
borderColor: hasTokens ? "var(--color-cf-accent)" : "var(--color-cf-border)",
481-
borderWidth: hasTokens ? 2 : 1.5
530+
borderStyle: "solid",
531+
borderColor: hasTokens
532+
? "var(--color-cf-accent)"
533+
: "var(--color-cf-border-strong)",
534+
borderWidth: 1.5
482535
}}
483536
data-testid={`place-node-${data.name}`}
484537
>
@@ -495,31 +548,28 @@ function PlaceNodeViewImpl({ data }: NodeProps<PlaceNode>) {
495548
isConnectable={false}
496549
/>
497550
</Surface>
498-
{hasTokens ? (
499-
<span
500-
data-testid={`place-token-badge-${data.name}`}
501-
className="absolute -right-1.5 -top-1.5"
502-
>
503-
<Badge
504-
variant="primary"
505-
className="inline-flex size-3 items-center justify-center rounded-full bg-cf-accent-tint p-0 text-[8px] font-semibold leading-none tabular-nums text-cf-accent-ink shadow-sm"
506-
>
507-
{data.tokens_count}
508-
</Badge>
509-
</span>
510-
) : null}
511551
</div>
512552
<div
513553
data-testid={`place-label-${data.name}`}
514554
className="mt-1 flex w-[96px] flex-col items-center leading-tight"
515555
>
516-
<span className="max-w-full truncate font-mono text-[11px] font-medium text-cf-ink">
517-
{data.name}
518-
</span>
556+
<div className="flex max-w-full items-baseline justify-center gap-1">
557+
<span className="truncate font-mono text-[11px] font-medium text-cf-ink">
558+
{data.name}
559+
</span>
560+
{hasTokens ? (
561+
<span
562+
data-testid={`place-token-badge-${data.name}`}
563+
className="font-mono text-[11px] font-bold tabular-nums text-cf-accent"
564+
>
565+
{data.tokens_count}
566+
</span>
567+
) : null}
568+
</div>
519569
{data.colour_set ? (
520-
<span className="max-w-full truncate font-mono text-[10px] text-cf-ink-muted">
570+
<code className="mt-0.5 max-w-full truncate rounded border border-cf-border bg-cf-canvas px-1 font-mono text-[10px] text-cf-ink-muted">
521571
{data.colour_set}
522-
</span>
572+
</code>
523573
) : null}
524574
</div>
525575
</div>
@@ -560,7 +610,7 @@ function TransitionNodeViewImpl({ data }: NodeProps<TransitionNode>) {
560610
style={{
561611
width: data.width,
562612
height: TRANSITION_H,
563-
borderColor: "var(--color-cf-border)",
613+
borderColor: "var(--color-cf-border-strong)",
564614
borderWidth: 1.5,
565615
boxShadow: pulseShadow ?? baseShadow,
566616
transform: pulsing ? "scale(1.05)" : "scale(1)",

dashboard/ui/src/routes/EnactmentDetailPage.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,53 @@ function DetailContent({
334334
}
335335
}, [])
336336

337+
// Enabled-edge accent: live mode reads directly from `transition.enabled_count`;
338+
// replay mode keeps a cumulative union of every edge that has been enabled at
339+
// any scrub position so the highlight stays sticky even when the operator
340+
// walks back to a version where the transition no longer has bindings.
341+
const currentlyEnabledEdgeIds = useMemo<ReadonlySet<string>>(() => {
342+
if (!diagram) return EMPTY_FIRING_SET
343+
const enabled = new Set<string>()
344+
const enabledTransitions = new Set<string>()
345+
for (const t of diagram.transitions) {
346+
if (t.enabled_count > 0) enabledTransitions.add(t.name)
347+
}
348+
diagram.arcs.forEach((arc, index) => {
349+
if (arc.orientation !== "p_to_t") return
350+
if (!enabledTransitions.has(arc.transition)) return
351+
enabled.add(`arc-${arc.orientation}-${arc.place}-${arc.transition}-${index}`)
352+
})
353+
return enabled
354+
}, [diagram])
355+
356+
const [stickyEnabledEdgeIds, setStickyEnabledEdgeIds] =
357+
useState<ReadonlySet<string>>(EMPTY_FIRING_SET)
358+
359+
useEffect(() => {
360+
if (replayState === null) {
361+
setStickyEnabledEdgeIds(EMPTY_FIRING_SET)
362+
return
363+
}
364+
setStickyEnabledEdgeIds((prev) => {
365+
let next: Set<string> | null = null
366+
for (const id of currentlyEnabledEdgeIds) {
367+
if (!prev.has(id)) {
368+
if (!next) next = new Set(prev)
369+
next.add(id)
370+
}
371+
}
372+
return next ?? prev
373+
})
374+
}, [replayState, currentlyEnabledEdgeIds])
375+
376+
const enabledEdgeIds = useMemo<ReadonlySet<string>>(() => {
377+
if (replayState === null) return currentlyEnabledEdgeIds
378+
if (stickyEnabledEdgeIds.size === 0) return currentlyEnabledEdgeIds
379+
const merged = new Set(stickyEnabledEdgeIds)
380+
for (const id of currentlyEnabledEdgeIds) merged.add(id)
381+
return merged
382+
}, [replayState, currentlyEnabledEdgeIds, stickyEnabledEdgeIds])
383+
337384
const [activeTab, setActiveTab] = useState<TabId>("markings")
338385
// Pending inspect target driven by NetDiagram node click. Cleared by
339386
// DebugTab once the dispatch fires so re-clicking the same transition
@@ -432,6 +479,7 @@ function DetailContent({
432479
onSelectTransition={onSelectTransition}
433480
firingEdgeIds={firingEdgeIds}
434481
firingDurationMs={firingDurationMs}
482+
enabledEdgeIds={enabledEdgeIds}
435483
/>
436484
</div>
437485
</LayerCard.Primary>

0 commit comments

Comments
 (0)