Skip to content

chart: Add appear motion to Plot and every chart - #3296

Merged
madcodelife merged 6 commits into
mainfrom
plot-appear-motion
Sep 28, 2026
Merged

madcodelife merged 6 commits into
mainfrom
plot-appear-motion

Conversation

@madcodelife

@madcodelife madcodelife commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Charts now draw their data in the first time they are painted, over 1000 ms on Chart.js' default easeOutQuart. The mainstream defaults are ECharts, Chart.js and Highcharts at 1000 ms, ApexCharts at 800 ms and Recharts at 1500 ms. The design system's enter curve, an exponential ease-out, made the draw-in read as a flash, so the appear uses its own curve. Each effect follows what the mainstream libraries do (checked against their sources):

Chart Appear Path cache
Line / Area / Candlestick / Sankey Revealed from the left under a content mask (ECharts, Highcharts, Recharts) Shapes unchanged, cache stays warm
Bar All bars grow out of the zero line together (Chart.js, Recharts, ApexCharts); value labels fade with their bar No cache (quads)
Pie The ring sweeps clockwise (Chart.js animateRotate, Recharts); leader-line labels fade in at the end Uncached only while sweeping
Radar Series grow out of the center (Chart.js, Recharts) No cache

Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. New data paints in place, so charts fed live quotes never replay. Reduced motion skips the appear.

The chart story gets a Replay button that re-keys the gallery so every chart draws in again.

Public API

gpui-base

pub struct PlotAppear                                  // this frame's appear of a plot
impl PlotAppear {
    pub fn complete() -> Self                          // a finished appear
    pub fn progress(&self) -> f32                      // eased 0..=1 for the whole plot
    pub fn is_appearing(&self) -> bool                 // still running
    pub fn staggered(&self, index: usize, count: usize, spread: f32) -> f32 // per-mark progress, starts spread over the first `spread`
}
impl PlotMotion {
    pub fn with_appear(self, appear: Transition) -> Self // how data draws in (zero by default)
    pub fn appear(&self) -> &Transition
}
trait Plot {
    fn interactive(&self) -> bool { true }             // hover/tooltip, now separate from `id`
    fn appear(&mut self, appear: PlotAppear, window: &mut Window, cx: &mut App) {} // receive the appear before hover/paint
    fn appear_generation(&self) -> Option<u64> { None } // `Some` opts in; a new value replays
}

gpui-component

On LineChart, AreaChart, BarChart, PieChart, RadarChart, CandlestickChart and SankeyChart:

pub fn appear(self, appear: bool) -> Self       // opt out, e.g. charts in list rows that remount on scroll
pub fn appear_key(self, key: impl Hash) -> Self // replay the appear when the key changes (symbol, period)

The theme projects PlotMotion::with_appear(1000 ms, easeOutQuart).

Breaking Changes

None at the source level. The three new Plot methods all have defaults (interactive → true, appear → no-op, appear_generation → None), so an existing hand-written plot compiles and behaves exactly as before: with an id it tracks hover as it did, and it tracks no appear and asks for no frames. PlotMotion gains a field behind a builder, and its default adds no motion.

Behavior changes worth knowing:

  • Built-in charts draw in by default. Opt out per chart with .appear(false).
  • interactive(false) charts now return an id. Plot::id on them is Some (it was None), and Plot::interactive is false. They still get no hitbox, but they now keep element state under that id. Like interactive charts, siblings built at one site without .id(...) share the appear state and path caches. That stays correct, but it rebuilds geometry more often.
  let chart = LineChart::new(data).interactive(false);
- assert!(Plot::id(&chart).is_none());
+ assert!(Plot::id(&chart).is_some());
+ assert!(!Plot::interactive(&chart));

Performance

  • Once the appear is done, it costs nothing extra: no mask, no frame requests, and PlotAppear returns early without sampling the easing. The per-frame state key is allocation-free, and the theme is borrowed rather than cloned.

  • A chart with .appear(false) (or a hand-written plot that doesn't opt in) tracks no state and requests no frames. Covered by tests.

  • Timing harness (not committed) on 100 line + 100 bar + 50 pie charts, 200 points each, --release, avg CPU per frame:

    during appear steady
    main ~420 ms ~411 ms
    this branch ~374 ms ~404 ms

    No regression. Per type during the appear: line 140 ms, bar 139 ms, pie 0.86 ms (vs 0.32 ms steady, from re-tessellating while sweeping).

Test plan

  • cargo test -p gpui-base -p gpui-component -p gpui-kit
  • New tests: appear timing over the theme duration, no frames once finished, reduced motion, opt-out requests no frames, generation replays, stagger math, interactive(false) keeps its id
  • cargo clippy -p gpui-base -p gpui-component --lib --tests
  • Ran the chart story: Replay restarts the appear. Mid-frame shows the area revealing from the left and pies sweeping; after 1 s everything is whole.

madcodelife and others added 6 commits September 28, 2026 21:05
Charts now draw their data in the first time they are painted: line, area,
candlestick and sankey charts reveal from the left, bars grow out of the zero
line one after another, a pie sweeps clockwise and a radar grows from its
center. Axes, grids and labels are there from the first frame, and the tooltip
waits until the data is whole.

Base adds `PlotMotion::with_appear`, `PlotAppear` and the `Plot::appear` /
`Plot::appear_generation` hooks, and splits `Plot::interactive` from
`Plot::id` so a chart with `interactive(false)` keeps its id for the appear
and its path caches. Charts take `appear(false)` and `appear_key(key)`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gallery is keyed on a generation the button bumps, so every chart gets
fresh element state and draws in again. Release notes cover the chart appear
motion and the `Plot::interactive` split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ECharts, Chart.js and Highcharts all default to about a second of drawing in,
ApexCharts to 800 ms and Recharts to 1500 ms. The design system's enter curve
is an exponential ease-out, nine-tenths done in the first quarter, which made
the 500 ms appear read as a flash; data now grows on ECharts' `cubicInOut`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The button joins `story_toolbar_group`, small and outlined like every other
story's toolbar. The toolbar stays put while the gallery scrolls, so it holds
the gap above the cards instead of the list's padding, and scrolled cards no
longer run up against the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bars grow out of the zero line together instead of one after another, the way
Chart.js, Recharts and ApexCharts draw them, and the appear eases on Chart.js'
default `easeOutQuart` over its 1000 ms. Lines and areas keep the reveal from
the left that ECharts, Highcharts and Recharts use, which also keeps their
cached paths whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nges

Every new `Plot` method has a default, so existing plots need no changes; the
note belongs with the appear motion, not in the list of required migrations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@madcodelife
madcodelife enabled auto-merge (squash) September 28, 2026 14:07
@madcodelife
madcodelife merged commit c06d463 into main Sep 28, 2026
12 checks passed
@madcodelife
madcodelife deleted the plot-appear-motion branch September 28, 2026 14:10
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