Skip to main content

Layout quality

graphlay::metrics::measure(&graph, &options, &layout) measures one drawing. It is pure geometry and deterministic, so the same code gives the same numbers and a run can serve as a baseline.

Defect counts (crossings, edges through nodes, overlaps) are what most layout papers optimise. Perceived-quality metrics (label size after scaling to a screen, density, aspect, fork/join alignment) catch drawings with no defects that are still hard to read, like a tidy tree 5000 px wide. Still look at the drawings, see Reviewing by eye.

Metrics​

Problem size​

metricmeaning
node_count, edge_countnodes and edges in the input, self-loops included
unrouted_edgesedges with fewer than two route points
self_loopsedges from a node to itself
parallel_edge_groups(from, to) pairs carrying more than one edge

Defects​

metricmeaning
crossingssegment pairs of different edges that properly cross. Crossings within 1 px of an endpoint the two edges share are not counted
overlapssegment pairs collinear within 0.5 px that overlap for more than 2 px, i.e., two edges drawn as one line. Tree elbows share a bus by design and count here
node_edge_hits(edge, node) pairs where the edge passes through a node it does not attach to
node_overlapsnode boxes overlapping by more than 0.5 px on both axes
endpoint_off_outlineroute ends more than 0.5 px off their node's border
max_endpoint_outline_gapthe largest such distance (px)
inward_endsroute ends whose first segment heads back across the border it attaches to
backward_edgesedges whose target is not strictly after its source along the layout direction

Route shape​

metricmeaning
bendsdirection changes over 5 degrees, summed over all edges
edges_with_bendsedges with at least one bend
total_edge_length, mean_edge_length, max_edge_lengthpolyline lengths (px)
detour_ratiopolyline length over the straight distance between the ends. 1.0 is straight
short_stepsjogs under 12 px between two runs, the target is 0
staircase_stepsconsecutive jogs in the same cross direction. A level change should be one jog
route_reversalshow often a route turns back on itself along either axis
perpendicular_endsends on a border across the layout axis, e.g., the top of a node in a left-to-right drawing
off_centre_endsends more than a third of the way from the middle of their border to its corner
converged_endsends sharing their attachment point with another end on the same node

Clearances​

Bigger is better, and each is None when undefined.

metricmeaning
min_node_gapsmallest gap between two node boxes, negative when they overlap
min_edge_node_clearancesmallest distance from an edge to a node it does not attach to
min_port_gapsmallest distance between two edge ends on the same node

Extent and perceived quality​

metricmeaning
width, height, areabounding box of all node boxes and route points
aspect_ratiowidth / height
fit_font_pxthe size of a 12.5 px label once the drawing is scaled to fit 1600x900, never above 12.5. Below about 7 labels are unreadable
densitynode box area over bounding-box area
aspect_penaltymax(aspect, 1 / aspect), 1.0 for a square
split_pairsfork/join blocks in the graph
split_alignedhow many of them the drawing shows as a block, fork and join level and the branches on either side
split_alignmentsplit_aligned / split_pairs, 1.0 without blocks

Labels​

All are 0 or None when no edge has a label.

metricmeaning
label_countedges that asked for a label and got an anchor
label_label_overlapspairs of label boxes overlapping by more than 0.5 px
label_node_overlaps(label, node) pairs overlapping, the label's own end nodes included
label_edge_hits(label, edge) pairs where another edge enters the label box
label_overlapsthe sum of the three, the one in the score
min_label_clearancesmallest distance from a label to another label, a node or another edge

Crossings by weight and angle​

metricmeaning
weighted_crossingssum of w(e) * w(f) over crossing segment pairs (Mennens et al. 2019)
thickness_weighted_crossingsthe same over Edge::thickness
crossing_angle_min, crossing_angle_meanacute angle between crossing segments, in degrees
crossings_below_30degcrossings shallower than 30 degrees

Flow​

metricmeaning
main_path_lennodes on the heaviest source-to-sink path, 0 when there is none
main_path_aligned_fractionshare of them within 2 px of the path's median cross coordinate
main_path_deviationmean cross-axis deviation from that median over the path's length
flow_consistencyshare of drawn length heading in the dominant pair of compass directions (Burattin et al. 2018)
flow_consistency_weightedthe same with each segment weighted by Edge::weight
path_turn_meanweighted mean of the total turn angle along each edge, in degrees
path_turn_maxthe largest single turn in the drawing

Crowding and lanes​

metricmeaning
parallel_run_lengthlength (px) over which two edges run within 10 degrees of parallel and closer than 1.5 stroke widths
block_intrusionsegments of other edges passing through a fork/join block's bounding box
lane_puritymean share of each group's cross-axis span that no other group occupies, 1.0 with fewer than two groups
lane_intrudersnodes of another group inside a group's interquartile band
whitespace_balancecoefficient of variation of node area over a grid of about one cell per node, 0.0 is even (Mooney et al. 2024)

Stability​

metrics::compare(&a, &b, &common) compares two drawings of graphs that share nodes, e.g., a full graph and a filtered view of it.

metricmeaning
hausdorfflargest distance (px) a common node moved
relative_euclideanmean relative change in the distance between sampled node pairs
orthogonal_orderingshare of sampled pairs whose left/right or above/below relation flipped
edge_shapemean shape change of the edges both drawings have
common_nodes, sampled_pairs, matched_edgeswhat the above were computed over

Score​

LayoutMetrics::score(&weights) sums the metrics into one number, lower is better. Use it to spot a regression, then look at the metric that moved. Counts enter unnormalised, so only scores of the same graph are comparable.

score = crossing * crossings
+ overlap * overlaps
+ node_edge_hit * node_edge_hits
+ node_overlap * node_overlaps
+ bend * bends
+ backward_edge * backward_edges
+ area * area / (10_000 * n)
+ edge_length * total_edge_length / (100 * n)
+ fit_font * (8 - fit_font_px).max(0)^2 [0 when fit_font_px == 0]
+ aspect * (aspect_penalty - 3).max(0)
+ split_misaligned * (split_pairs - split_aligned)
+ label_overlap * label_overlaps
+ main_path * main_path_len * (1 - main_path_aligned_fraction)
+ flow * routed_edges * (1 - flow_consistency)

n is node_count.max(1) and routed_edges is edge_count - unrouted_edges. The font term is quadratic because 7 px is hard to read and 4 px is impossible. The other metrics are reported but not scored.

weightbalanced() (default)readability()
crossing1010
overlap44
node_edge_hit2525
node_overlap5050
bend11
backward_edge0.50.5
area121
edge_length121
fit_font1020
aspect812
split_misaligned1012
label_overlap2525
main_path1012
flow23

readability() uses compactness only as a tie-breaker and weighs the perceived-quality terms higher. In both, a drawing that is bad at one term pays about what a drawing with many crossings pays. The flow term counts every routed edge, so it gets a fifth of the weight of the terms that count nodes.

The eval harness​

The eval example lays out every fixture under fixtures/ (format: fixtures/FORMAT.md), measures it and prints a table. --help lists every flag.

cargo run -p graphlay --release --features fixtures --example eval -- [args]

# --filter <substr> only fixtures whose name or kind contains <substr>
# --svg-out <dir> render one SVG per fixture into <dir>
# --baseline <file.json> compare against saved metrics, print deltas and mark regressions
# --save-baseline <file> write this run's metrics as a new baseline
# --repeat <n> lay each fixture out n times and take the median duration
# --effort <level> fast, balanced (default) or thorough
# --wide print a second table with the metrics the main table leaves out
# --phase-profile the staircase steps of the long edges after each pipeline phase
# --stability lay each fixture out again without its lightest 10% of edges and
# report how far the drawing moved

Further flags override one option for every fixture: --port-sides, --merge-edges, --group-lanes, --placement, --labels, --label-reserve, --lane-budget and --lane-area.

In the table, fit is fit_font_px, split is split_aligned/split_pairs, wcross is weighted_crossings, lbl is label_overlaps, main is main_path_aligned_fraction and flow is flow_consistency.

A typical check of a change:

cargo run -p graphlay --release --features fixtures --example eval -- \
--svg-out /tmp/before --save-baseline /tmp/before.json
# change the engine
cargo run -p graphlay --release --features fixtures --example eval -- \
--svg-out /tmp/after --baseline /tmp/before.json

The exit code is 1 when a fixture fails to lay out. Timings vary between runs, so compare --repeat 3 or more before reading anything into a small difference.

Reviewing by eye​

Some unreadable drawings score well. Render the corpus with --svg-out before and after a change and compare the pairs.

The SVGs carry a viewBox, so a headless browser screenshot at a smaller window scales the whole drawing the way a reader's screen does:

chromium --headless=new --no-sandbox --hide-scrollbars \
--window-size=1600,900 --screenshot=out.png file:///abs/path/to/drawing.svg

Chromium adds an 8 px margin, so make the window about 20 px larger than the drawing. To check a suspected overlap, copy the SVG, set its viewBox to the region at 1:1 and screenshot that.

Things to check, with the metric that should agree:

  • Can you read a label? (fit_font_px)
  • Are there big empty regions crossed by a few long edges? (density, whitespace_balance)
  • Do long, nearly straight edges graze nodes? (node_edge_hits)
  • Do fork/join blocks read as blocks? (split_alignment)
  • Can you tell which edge a label belongs to? (label_overlaps, min_label_clearance)
  • Does the heaviest path run straight? (main_path_aligned_fraction)