Skip to content

chart: Introduce SankeyChart#2538

Merged
madcodelife merged 15 commits into
mainfrom
chart-sankey
Jul 13, 2026
Merged

chart: Introduce SankeyChart#2538
madcodelife merged 15 commits into
mainfrom
chart-sankey

Conversation

@madcodelife

@madcodelife madcodelife commented Jul 6, 2026

Copy link
Copy Markdown
Member
image

@madcodelife
madcodelife marked this pull request as draft July 6, 2026 09:35
@madcodelife
madcodelife force-pushed the chart-sankey branch 3 times, most recently from 107ff6f to aa73e50 Compare July 7, 2026 07:21
madcodelife and others added 7 commits July 8, 2026 19:10
Add a Sankey diagram chart to the plot system:

- `plot/shape/sankey.rs`: layout generator ported from d3-sankey
  (node depth/height via BFS, `Left`/`Right`/`Center`/`Justify`
  alignments, iterative relaxation with collision resolution), plus a
  `sankey_link_path()` helper building the filled cubic-bezier ribbon.
  Circular links return `SankeyError` instead of panicking.
- `chart/sankey_chart.rs`: `SankeyChart` with builder-style API
  (`node_width`, `node_padding`, `node_align`, `node_color`,
  `node_label`, `value_label`, `link_opacity`, `min_link_width`).
  Ribbons are filled with a source-to-target color gradient; label
  margins are reserved via a two-pass layout.
- Story: TSLA income statement examples (FY 2025 with sqrt-compressed
  values, FY 2024 linear) rendered from a fixture shaped like the real
  API response.
- Docs: `SankeyChart` sections in both `en` and `zh-CN` locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove the no-op `id()` builder: it opted into the `IntoPlot` tooltip
  machinery (full-window `refresh()` on every hover) while `tooltip_state`
  was never implemented, so it only caused repaint churn with no tooltip.
- Replace the `ScaleOrdinal` color lookup (O(n^2) scan + Vec alloc, with a
  dead `unwrap_or_else` fallback) with a direct `palette[index % 5]`.
- Only reserve the top label band when middle columns exist
  (`layer_count > 2`), and cap the top/bottom margins like the horizontal
  ones so a short chart no longer collapses the flow area to 1px.
- Deduplicate `compute_node_depths`/`compute_node_heights` into a single
  `compute_node_ranks(forward)`.
- Fix the docs' value-compression example: `FlowNode` now carries the
  `value` field the `value_label` closure reads (en + zh-CN).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a `SankeyLabel` line type (text with optional color and font size)
and a `labels` builder that returns one styled line per label, taking
precedence over `node_label`/`value_label`. The chart keeps handling
placement, alignment and margin reservation; the top band and label
margins now size to the actual label blocks.

The TSLA FY 2025 story example uses it to render the reference design's
three-line label: value, year-over-year growth (▲ green / ▼ red), and
name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First/last-column label blocks are vertically centered beside their
node, so a short node near the top or bottom edge pushed part of its
label outside the plot where it was clipped. Clamp the block into the
plot area instead, keeping it beside the node whenever it fits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node heights take the max of incoming and outgoing sums, so on an
imbalanced graph (e.g. sqrt-compressed link values, where sums are not
additive) the smaller side's ribbons no longer covered the node,
leaving gaps between the node and its links.

Give each ribbon end its own width: the links on a node side share the
node's full height in proportion to their values, so both sides are
always fully covered and the ribbon transitions smoothly between its
two ends. On a balanced graph this is identical to the previous
uniform-width stacking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
madcodelife and others added 7 commits July 9, 2026 14:26
A first/last-column label wider than its reserved (and capped) margin
was drawn at full width, spilling out of the plot area. Truncate it
with a trailing ellipsis to the reserved width instead. Middle-column
labels sit above interior nodes and never reach the plot edge, so they
are left untruncated.

Add a reusable `truncate_text_to_width` helper (binary search over char
boundaries) to the plot label module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A crowded column forces a small scale, so sparser columns don't fill
the height, and the relaxation aligns them to their flows' weighted
center — leaving the trunk sitting high with empty space below. Center
each column's stack in the extent by translating it (preserving the
relaxation's within-column arrangement and the ribbon fits), so the
diagram stays balanced on the canvas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Centering aligns adjacent single-node columns of equal height exactly,
so the ribbon between them is a flat rectangle. Nudge the odd columns
of such a run off the center line (bounded by the available slack, so a
full-height column stays put and never leaves the extent) to turn the
flat ribbon into a gentle S-curve. Only the degenerate flat case is
touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A very long first/last-column label reserved up to a shared 60% of the
width, so even after truncation it dominated the chart and squeezed the
flow into a narrow band. Cap each side independently at 20% of the width
instead, so a long label is truncated to a modest column and the flow
area stays dominant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add `SankeyValueScale { Linear, Sqrt }` and a `value_scale` builder so a
wide value range can be compressed without the caller pre-transforming
its data: a dominant flow no longer dwarfs the small ones and the small
ones stay visible. The scale is applied once at the layout entry, so all
downstream math stays additive in scaled space and every node remains
exactly filled by its ribbons (children match their parent's height)
under either scale. Labels receive the raw throughput, computed from the
input links, not the scaled layout value.

The TSLA story examples now use `.value_scale(Sqrt)` instead of manually
square-rooting the link values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-ups from an Opus review of the sankey implementation (no
correctness bugs were found):

- Assert link ends stay attached to their nodes after
  `stagger_flat_columns` (guards a source/target mix-up in
  `apply_layer_offsets`), and that `center_columns` keeps every node in
  the extent.
- Test `raw_throughput`: it returns raw per-node sums and, under
  `Sqrt`, differs from the layout's scaled node value (the label
  value-leak guard).
- Bound middle-column labels by the distance to the plot edge so a
  runaway one is truncated rather than drawn off-plot, instead of the
  previous unenforced "never reaches the edge" assumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-ups from a comment review (comments were otherwise accurate):

- Document the undocumented public items: `SankeyError`, `Sankey::new`,
  `SankeyLink::new`, `SankeyChart::new`, `SankeyLabel::new`.
- State the "unset by default" behavior on `node_label`/`value_label`/
  `labels`, and that the value passed to them is the raw (unscaled)
  throughput.
- Fix the first-pass comment: it uses only `node.layer`, not the
  topology's value (labels come from `raw_throughput`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@madcodelife
madcodelife marked this pull request as ready for review July 13, 2026 03:04
@madcodelife
madcodelife merged commit e8564ca into main Jul 13, 2026
4 checks passed
@madcodelife
madcodelife deleted the chart-sankey branch July 13, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant