Skip to content

Commit 9b92240

Browse files
aksOpsclaude
andauthored
feat(ui): dagre layered service map — routed edges, level-of-detail, draggable (#113)
React Flow service map over a dagre layered layout (@dagrejs/dagre, MIT): crossing-minimised left→right, edge-routing waypoints so links thread between ranks and meet node borders; level-of-detail status dots below 0.62 zoom; draggable nodes (drags survive metric polls, fresh layout re-seeds); right side-panel autosizes to service names (fixes the horizontal scrollbar). dagre runs synchronously (deterministic, instant at normal scale). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LDQVJrixs2nJoea67a8pEG
1 parent 56efc11 commit 9b92240

9 files changed

Lines changed: 496 additions & 146 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
OtelContext is a self-hosted OTLP observability platform. Single Go binary with embedded React frontend.
66
- **Backend:** Go 1.25, native `net/http` (no frameworks), GORM ORM, gRPC + HTTP for OTLP ingestion
7-
- **Frontend:** React 19 + TypeScript + TanStack Query/Virtual + wouter + Radix primitives + cmdk palette + hand-rolled token CSS (`ui/src/styles/tokens.css`) with CSS Modules. The **service map renders via React Flow (`@xyflow/react`, MIT)** fed by our own deterministic radial layout (`ui/src/lib/radialLayout.ts`phyllotaxis, no physics); React Flow owns pan/zoom/fit/minimap/grid/a11y, nodes are token-themed React components, edges are straight centre-to-centre. No UI framework: `@ossrandom/design-system`, cytoscape, uplot remain removed (cytoscape's physics hairball → deterministic layout + React Flow; the prior hand-rolled-SVG map was replaced by React Flow on 2026-06-18)
7+
- **Frontend:** React 19 + TypeScript + TanStack Query/Virtual + wouter + Radix primitives + cmdk palette + hand-rolled token CSS (`ui/src/styles/tokens.css`) with CSS Modules. The **service map renders via React Flow (`@xyflow/react`, MIT)** over a dagre layered layout (`ui/src/lib/dagreLayout.ts``@dagrejs/dagre`, MIT: crossing-minimised, left→right, with edge-routing waypoints) computed synchronously. React Flow owns pan/zoom/fit/minimap/grid/a11y; nodes are token-themed React components — draggable, and they collapse to status **dots** below a zoom threshold (level-of-detail) so they stop occluding edges at scale; edges route through the dagre waypoints and meet node borders. No UI framework: `@ossrandom/design-system`, cytoscape, uplot remain removed (cytoscape's physics hairball → deterministic layered layout + React Flow; the prior hand-rolled-SVG map was replaced by React Flow on 2026-06-18, then moved to the dagre layered layout)
88
- **Ports:** gRPC `:4317` (OTLP), HTTP `:8080` (API + HTTP OTLP + WebSocket + UI)
99

1010
## Strict Rules

ui/budgets.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
2-
"$comment": "Gzipped size budgets enforced by scripts/check-budgets.mjs (npm run check-budgets). 'initial' = assets referenced from dist/index.html (entry script + modulepreloads + stylesheets). Fonts are raw woff2 bytes (already compressed). lazyChunkExceptions maps a chunk-name prefix (before the content hash) to a per-chunk cap. The ConstellationHome lazy chunk carries the service map, which now renders via React Flow (@xyflow/react, MIT) instead of hand-rolled SVG — React Flow + zustand + d3-zoom add ~52 KB gz, all lazy (initial JS is unaffected, still ~106 KB). Measured ConstellationHome chunk: 61.3 KB gz; cap set to actual + headroom.",
2+
"$comment": "Gzipped size budgets enforced by scripts/check-budgets.mjs (npm run check-budgets). 'initial' = assets referenced from dist/index.html (entry script + modulepreloads + stylesheets). Fonts are raw woff2 bytes (already compressed). lazyChunkExceptions maps a chunk-name prefix (before the content hash) to a per-chunk cap. The ConstellationHome lazy chunk carries the service map: React Flow (@xyflow/react, MIT) + the dagre layout engine (@dagrejs/dagre + graphlib, MIT — crossing-minimised layout + edge routing) computed synchronously. All lazy initial JS is unaffected (~103 KB). Measured ConstellationHome chunk ~72 KB gz; cap set to actual + headroom.",
33
"gzipKb": {
44
"initialJs": 118,
55
"initialCss": 6,
66
"fonts": 75,
77
"lazyChunkDefault": 10,
88
"lazyChunkExceptions": {
9-
"ConstellationHome": 66
9+
"ConstellationHome": 76
1010
}
1111
}
1212
}

ui/package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"check-budgets": "node scripts/check-budgets.mjs"
1414
},
1515
"dependencies": {
16+
"@dagrejs/dagre": "^3.0.0",
1617
"@radix-ui/react-dialog": "^1.1.16",
1718
"@radix-ui/react-dropdown-menu": "^2.1.17",
1819
"@radix-ui/react-tabs": "^1.1.14",

ui/src/components/map/FlowMap.module.css

Lines changed: 120 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
* (background grid, panes, minimap, handles) to our --bg-inset instrument
66
* surface and (b) style the custom node/edge classes the component renders.
77
*
8-
* Token-only colours (tokens.css). All motion is keyed off --dur-* tokens, which
9-
* the global prefers-reduced-motion block zeros — so reduced motion collapses
10-
* every transition/animation here without a local media query.
8+
* Token-only colours (tokens.css). Token-driven motion is keyed off --dur-*,
9+
* which the global prefers-reduced-motion block zeros. The ONE exception is
10+
* React Flow's native edge animation (animated edges), which runs on the
11+
* library's own keyframes — it gets an explicit prefers-reduced-motion guard at
12+
* the bottom of this file.
1113
*/
1214

1315
/* ---- the wrapper that holds the whole map ---- */
@@ -67,23 +69,48 @@
6769
box-shadow: 0 0 0 1px var(--accent);
6870
}
6971

70-
/* Hidden centred handle: edges attach here, but FloatingEdge draws
71-
* centre-to-centre so position is cosmetic. Must visually disappear AND not
72-
* eat clicks. !important beats React Flow's base .react-flow__handle (a 6×6
73-
* #333/#141414 dot with a border). */
72+
/* ---- level-of-detail: a status dot replaces the chip when zoomed out (dense
73+
* 120-service view), so the 168px boxes stop occluding the routed edges. The
74+
* dot is centred in the node box (dagre's edge waypoints still meet the box
75+
* border) and carries the status hue. ---- */
76+
.dotNode {
77+
display: flex;
78+
align-items: center;
79+
justify-content: center;
80+
width: 100%;
81+
height: 100%;
82+
cursor: pointer;
83+
}
84+
.dotNode::before {
85+
content: '';
86+
width: 18px;
87+
height: 18px;
88+
border-radius: 50%;
89+
background: var(--node-color);
90+
box-shadow: 0 0 0 2px var(--bg-inset);
91+
transition: box-shadow var(--dur-2) var(--ease);
92+
}
93+
/* Inspected / blast-root dot: accent ring, matching the chip's selected ring. */
94+
.dotSelected::before {
95+
box-shadow:
96+
0 0 0 2px var(--bg-inset),
97+
0 0 0 4px var(--accent);
98+
}
99+
100+
/* Connection anchor, kept INVISIBLE. React Flow needs a source/target handle
101+
* per node, but the floating edge computes the real border attachment point, so
102+
* the handle dot itself must not show — otherwise edgeless nodes sprout a stray
103+
* connector. opacity:0 keeps the element measured so edge routing still resolves
104+
* its position; it never eats clicks. */
74105
.handle {
75-
position: absolute !important;
76-
left: 50% !important;
77-
top: 50% !important;
78-
width: 1px !important;
79-
height: 1px !important;
80-
min-width: 0 !important;
81-
min-height: 0 !important;
82-
border: 0 !important;
83-
background: transparent !important;
84-
opacity: 0 !important;
85-
pointer-events: none !important;
86-
transform: translate(-50%, -50%);
106+
width: 6px;
107+
height: 6px;
108+
min-width: 0;
109+
min-height: 0;
110+
border: 0;
111+
background: transparent;
112+
opacity: 0;
113+
pointer-events: none;
87114
}
88115

89116
/* Status dot — the same hue as the chip outline. */
@@ -115,34 +142,6 @@
115142
color: var(--text-2);
116143
}
117144

118-
/* ---- custom edge: FloatingEdge renders a raw <path className="edge ..."> ---- */
119-
/* Hairline link, legible on both themes against the field. --edge-w is set
120-
* inline from the call count. */
121-
.edge {
122-
fill: none;
123-
stroke: var(--text-3);
124-
stroke-width: var(--edge-w, 1.5);
125-
opacity: 0.75;
126-
}
127-
128-
/* Failing edge: --crit, full strength, marching dashes that drain toward the
129-
* target. The march runs on calc(var(--dur-3) * 6) — so reduced motion
130-
* (--dur-3: 0ms) yields a 0s duration and the dashes stop, leaving a static
131-
* red dashed line. */
132-
.edgeFailing {
133-
stroke: var(--crit);
134-
opacity: 1;
135-
stroke-dasharray: 4 4;
136-
animation: march calc(var(--dur-3) * 6) linear infinite;
137-
}
138-
139-
@keyframes march {
140-
to {
141-
/* one full dash + gap (4 + 4) → seamless loop. */
142-
stroke-dashoffset: -8;
143-
}
144-
}
145-
146145
/* ============================================================================
147146
* React Flow base overrides (file-scope :global — these target React Flow's
148147
* OWN DOM, which is a sibling of our chips, not nested under any module class).
@@ -188,3 +187,77 @@
188187
stroke-width: 1;
189188
}
190189

190+
/* ============================================================================
191+
* React Flow Controls + MiniMap — themed to the dark token instrument surface
192+
* so the map reads as a first-class graph tool, not the library's default
193+
* white chrome. (file-scope :global — these target React Flow's own DOM.)
194+
* ==========================================================================*/
195+
196+
/* Zoom / fit control cluster: a hairline token-themed stack, not a white box. */
197+
.controls {
198+
border: 1px solid var(--stroke-1);
199+
border-radius: var(--radius-2);
200+
overflow: hidden;
201+
background: var(--bg-raised);
202+
box-shadow: none;
203+
}
204+
.controls :global(.react-flow__controls-button) {
205+
width: 28px;
206+
height: 28px;
207+
border: none;
208+
border-bottom: 1px solid var(--stroke-1);
209+
background: var(--bg-raised);
210+
color: var(--text-2);
211+
fill: var(--text-2);
212+
transition:
213+
background var(--dur-1) var(--ease),
214+
color var(--dur-1) var(--ease);
215+
}
216+
.controls :global(.react-flow__controls-button):last-child {
217+
border-bottom: none;
218+
}
219+
.controls :global(.react-flow__controls-button):hover {
220+
background: var(--bg-overlay);
221+
color: var(--text-1);
222+
fill: var(--text-1);
223+
}
224+
.controls :global(.react-flow__controls-button svg) {
225+
fill: currentColor;
226+
}
227+
228+
/* Minimap: dark inset panel, hairline border; the out-of-view area dimmed by a
229+
* token overlay; dots carry the same status hue as the chips. */
230+
.minimap {
231+
border: 1px solid var(--stroke-1);
232+
border-radius: var(--radius-2);
233+
background: var(--bg-inset);
234+
overflow: hidden;
235+
}
236+
.minimap :global(.react-flow__minimap-mask) {
237+
fill: var(--bg-inset);
238+
opacity: 0.6;
239+
}
240+
/* Status fills override React Flow's base .react-flow__minimap-node fill. */
241+
.miniCrit {
242+
fill: var(--crit) !important;
243+
}
244+
.miniWarn {
245+
fill: var(--warn) !important;
246+
}
247+
.miniOk {
248+
fill: var(--ok) !important;
249+
}
250+
.miniUnknown {
251+
fill: var(--unknown) !important;
252+
}
253+
254+
/* React Flow's native animated edges run on the library's own keyframes (not a
255+
* --dur token), so the global reduced-motion rule can't reach them. Stop the
256+
* marching dashes for users who ask for reduced motion — leaving a static
257+
* dashed edge and dropping the continuous edge-layer repaint for them. */
258+
@media (prefers-reduced-motion: reduce) {
259+
:global(.react-flow__edge.animated path) {
260+
animation: none;
261+
}
262+
}
263+

0 commit comments

Comments
 (0)