Charts/Core charts
Node Graph
<NodeGraph /> · 13 examples
Services as circles, calls as arrows, and the health of each one drawn around its edge.
Overview
Every architecture diagram begins life as a tidy drawing on a whiteboard and ends as a lie. Somebody adds a queue, somebody else adds a cache, a third person adds a service called "misc", and nobody updates the drawing. A node graph is the architecture diagram that cannot lie, because it is built from what the system actually did.
Each node is a service, a stage or a dependency. Each edge is a call between two of them. The twist, from Grafana's Node graph panel which this chart ports, is that nodes carry numbers. Two stats sit inside the circle, a title below, and the outline is split into coloured arcs, so a node can say "98% success, 2% errors" without being clicked.
That is the whole appeal. A table of services answers "what is the error rate of checkout?". A node graph answers "which of the services that checkout depends on is the one on fire?", and gives you the other thirty at the same time.
Props
API
<NodeGraph> draws a directed graph: services and the calls between them, pipeline stages, dependencies. Nodes are circles with two stats inside, a title below and coloured arc sections around them; edges are arrows that can carry stats of their own. It is a port of Grafana's Node graph panel; see Coming from Grafana.
use zopra_charts::*;<NodeGraph>GraphNodeGraphEdge- Arcs and colours
- Layouts
- Interaction
- Enums and types
- Coming from Grafana
Not sure which option fits? See the guide.
<NodeGraph>
let nodes = vec![
GraphNode::new("api").sub_title("gateway").main_stat("120 req/s").arcs([0.98, 0.02]),
GraphNode::new("db").sub_title("postgres").main_stat("40 ms"),
];
let edges = vec![GraphEdge::new("api", "db").main_stat("12 ms")];
let health = vec![
ArcSection::new("success", rgb(0x40a02b)),
ArcSection::new("errors", rgb(0xd20f39)),
];
<NodeGraph nodes={nodes} edges={edges} arcs={health} />| Prop | Type | Default |
|---|---|---|
nodes | Vec<GraphNode>, Signal<Vec<GraphNode>> or your own Dataset<GraphNode> | required |
edges | Vec<GraphEdge>, Signal<Vec<GraphEdge>> or your own Dataset<GraphEdge> | required |
layout | NodeLayout | Layered |
zoom_mode | ZoomMode | Cooperative |
arcs | Vec<ArcSection>: the name and colour of each arc section | none |
node_limit | usize: nodes shown at once | 200 |
formats | StatFormats: how numeric stats are written | numbers as they are |
legend | bool: the arc legend below the graph | true |
on_node_click | Fn(&GraphNode, &mut Window, &mut App) | a details popover |
on_edge_click | Fn(&GraphEdge, &mut Window, &mut App) | a details popover |
class, key |
Pass Signals when the graph changes over time: layouts are computed once per change and cached.
GraphNode
| Method | Effect | Default |
|---|---|---|
GraphNode::new(id) | a node with this unique id | title = id |
title(text), sub_title(text) | two lines under the circle | |
main_stat(value), secondary_stat(value) | two lines inside the circle: numbers, text, bool or a StateValue | empty |
arcs([..]) | one fraction from 0 to 1 per ArcSection, in the same order | none |
color(colour) | the outline colour when there are no arcs | grey |
radius(px) | the circle's radius | 40. |
highlighted(bool) | fills the circle in red | false |
detail(name, value) | an extra row in the details popover; call it once per row | none |
GraphEdge
| Method | Effect | Default |
|---|---|---|
GraphEdge::new(source, target) | an arrow from the node with id source to the node with id target | |
id(text) | a unique id | "source→target" |
main_stat(value), secondary_stat(value) | a label shown when you hover the edge or one of its nodes | empty |
thickness(px) | line width | 1. |
color(colour) | line colour | grey |
dashed(bool) | a dashed line | false |
highlighted(bool) | red and one pixel thicker | false |
detail(name, value) | an extra row in the details popover | none |
Edges whose source or target id is not among the nodes are left out.
Arcs and colours
Each node's outline is drawn from its arcs values, in the order of the chart's arcs sections, clockwise from the top:
- A value of 1 or more paints the whole outline in that section's colour.
- Otherwise each non-zero value takes that share of the circle. Shares past 100% are cut off.
- A node without non-zero arcs gets its
color, or grey.
The legend lists the sections. Clicking a section switches to the grid layout sorted by it; click again to reverse the order.
Layouts
NodeLayout | Placement |
|---|---|
Layered (default) | left to right in layers, so every edge points right where it can; crossings are reduced; loops are allowed |
Force | a physical simulation that pulls connected nodes together and pushes others apart (Grafana's d3-force settings) |
Grid | rows of nodes in data order, or sorted by an arc from the legend |
The buttons in the top-right corner switch layouts at any time.
Node limit. With more nodes than node_limit, the graph shows the nodes nearest the roots (nodes with no incoming edges) and collapses each unexplored branch into a "N nodes" marker. Click a marker to centre the view on that branch. In the grid layout, the limit shows a window of the sorted nodes.
Interaction
- Pan by dragging the background. Zoom with the + and − buttons, or the mouse wheel: with
ZoomMode::Cooperative, hold Ctrl (⌘ on macOS) so the page can still scroll; withGreedy, the wheel always zooms. The ⛶ button resets the view and fits the graph. - Hover a node to highlight it and its neighbours, dim the rest, show its full title and the stats of its edges. Hover an edge to show its stats.
- Click a node or edge for a popover with its title, stats, arc shares and
detailrows, or handle the click yourself withon_node_click/on_edge_click.
The graph starts fitted to the panel, never zoomed in past 100%.
Enums and types
| Type | Values |
|---|---|
NodeLayout | Layered (default), Force, Grid |
ZoomMode | Cooperative (default), Greedy |
ArcSection | ArcSection::new(name, colour) |
StatFormats | StatFormats::new() with .node_main(f), .node_secondary(f), .edge_main(f), .edge_secondary(f), each fn(f64) -> SharedString for numeric stats |
Coming from Grafana
| Grafana | Here |
|---|---|
| Nodes frame: id, title, subtitle, mainstat, secondarystat | GraphNode::new(id), title, sub_title, main_stat, secondary_stat |
arc__* fields, arc colours | arcs([..]) and the chart's arcs |
color, nodeRadius, highlighted, detail__* | color, radius, highlighted, detail |
| Edges frame: id, source, target, mainstat, secondarystat | GraphEdge::new(source, target), id, main_stat, secondary_stat |
thickness, color, strokeDasharray, highlighted, detail__* | thickness, color, dashed, highlighted, detail |
| Layout algorithm | layout |
| Zoom mode | zoom_mode |
| Nodes / edges main and secondary stat unit | formats |
| Node limit | node_limit |
| Data links in the context menu | on_node_click, on_edge_click |
Differences:
- Grafana lays out every node and then applies the limit; here only the shown nodes are laid out, so a limited graph stays compact.
- The graph starts fitted to the panel instead of at 100%.
- Node icons and fixed node positions are not available.
Run them
13 examples
Press Run to load an example into the live render at the top of the page. Open one to read its code.
How to read it
- 01Start at the left in the layered layout: roots (nodes nothing calls) are there, and calls go rightward.
- 02Scan the outlines for colour. An arc split between success and error colours tells you the share of each before you read a number.
- 03Hover a node: it and its neighbours stay bright, the rest dim, and the stats on the connected edges appear. Hover an edge for its own stats.
- 04Click a node or edge for a popover with its stats and extra rows. Drag the background to pan, use + and - to zoom (hold Ctrl with the wheel), and the layout buttons to try Force or Grid.
When to use it, and when not to
Reach for it when
- You want to see how a set of services depend on each other, with health or load on each, in one view.
- The question is "where is the problem?": a red arc on a node, and the edges that touch it, point at the culprit.
- The data is a graph: pipeline stages, dependency trees, data lineage, anything with a source and a target.
- You want a starting map for an incident, where hovering a node dims everything except it and its neighbours.
Skip it when
- The data is about one request in time. Use Traces, where the x axis is a clock.
- You need to compare flow volumes accurately. Edge thickness is a hint. A Sankey gives width proportional to flow.
- The graph has thousands of nodes and no obvious roots. The chart caps what it draws (200 by default) and the rest will be collapsed into markers.
- The relationships are dense and between a small set of groups. A Chord Diagram shows that better.
The math behind it
Layered layout
The default layout turns the graph into columns, so edges point rightward wherever they can. First, cycles are broken: a depth-first search reverses any edge that points back to a node on the current path, and self-loops are ignored. On the resulting acyclic graph each node gets a layer equal to the length of the longest path leading into it.
Here is the layer of node and its predecessors. Edges that span more than one layer get invisible dummy nodes in the layers between, so they take part in ordering.
Reducing crossings and placing nodes
Within each layer, the order of nodes is chosen by 12 alternating sweeps. On a downward sweep every node is sorted by the mean position of its neighbours in the layer above. On an upward sweep, by those below. This barycentre heuristic is the classic cheap way to cut edge crossings. It does not find the minimum, and does not claim to.
Vertical coordinates start evenly spaced at a step of 140 px (a 100 px circle plus a 40 px gap) and are then pulled toward the mean of their neighbours for 8 passes, with the constraint that nodes in a layer cannot overlap. Columns are 160 px apart. The result is centred on the origin.
Force layout, grid and arcs
Force runs a physical simulation for 300 ticks with Grafana's d3-force settings: links pull their endpoints toward a rest length of 150 px with strength 0.5, a collision radius of 100 px pushes overlapping nodes apart, and a velocity decay of 0.6 damps the motion. Roots and their direct children start on a coarse tree and keep their horizontal position, which is why force layouts here still flow left to right. Grid ignores edges and fills rows in data order.
A node's outline is divided by its arcs values , clockwise from the top. If any value is 1 or more, the whole outline takes that colour. Otherwise each non-zero gets that share of the circle and anything beyond 100% in total is cut off.
Real life usage
Nobody calls the thing that everybody calls
Priya joined a logistics company as its fourth platform engineer, inheriting a system of forty-odd services and a wiki page titled "Architecture (current)" that had last been edited by someone who had since left to open a bakery. Her first week was supposed to be reading. She spent it asking people what depended on what, and getting forty-odd slightly different answers.
The question became urgent in her third week, when the parcel-routing service started returning errors for about three percent of requests. Three percent is the dangerous number: too small to page anyone, large enough that somebody's delivery is a week late.
She did not need another dashboard of per-service charts. She needed the shape of the failure: which of the forty services were affected, in what order, and which one they all seemed to lean on. That is a question about edges, not values.
She exported the service-to-service call counts and error rates from the metrics store and drew them with <NodeGraph> in a small desktop tool: nodes with the request rate inside, arcs for success and error share, edges labelled with latency. The layered layout put the public API on the left and the databases on the right, and made the shape plain at once.
Three services had a faint red arc. Every edge feeding them came from the routing service, and the routing service had one edge, thick and dark, into a node called geocoder-legacy whose outline was almost half red. It was the only service in the graph that the wiki did not mention.
The geocoder was running on a single instance and timing out under load. Adding two replicas dropped routing errors from 3.1% to 0.2% by the afternoon. The wiki page was updated, and the bakery was informed that the old owner was off the hook.
Priya's graph now lives on a side monitor. It has, as far as she can tell, the same effect as a smoke detector: nobody looks at it until it is the only thing that matters.
Pitfalls
- Layout is not meaning. In the layered layout, position along the x axis is the number of hops, not time or size. In the force layout, distance is only loosely related to anything.
- Cycles are drawn, but bent. The layered layout reverses back-edges for placement only. A call loop will still be an arrow in the right direction, but it may cross the whole graph to get there.
- Arcs that sum past 1. Each value is a share, and shares past 100% in total are cut off. If your two arcs are "success" and "errors", make sure they add to at most 1.
- The node limit. With more than
node_limitnodes, only those nearest the roots are laid out and the rest become "N nodes" markers. Check for them before concluding a service is not there. - Dangling edges. An edge whose source or target id is not among the nodes is silently left out.
From around the web