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
madcodelife added a commit that referenced this pull request Sep 29, 2026
…3308)

## Summary

A plot's appear (#3296) lives in its element state. GPUI drops element
state for anything not painted in a frame, so a plot inside a virtual
list — `list`, `uniform_list`, a `List` row — forgets its appear once it
scrolls past the overdraw and draws in again every time it comes back.
The only workaround so far was `.appear(false)`, which gives up the
appear entirely.

`PlotAppearScope` wraps the region that repaints its plots on and off:

```rust
PlotAppearScope::new(("transcript", conversation_id), list(state, render_row).flex_1())
```

- A plot inside it that finished appearing is remembered by its global
element id and appear generation. Painted again after a gap, it gets
`PlotAppear::complete()` straight away: no `Presence` tracking and no
frames, and hover is not held back.
- A new `appear_generation` still replays, and a plot taken away
mid-appear draws in again from the start.
- The memory is the scope's own element state, so it lasts exactly while
the scope is painted every frame. Closing the view, or giving the scope
another id (name it after the content), draws its plots in afresh.
- The innermost scope wins. The scope takes no part in layout: it hands
on its child's `LayoutId`, so a self-sizing child such as
`list(..).flex_1()` keeps working unchanged.
- Without a scope, behavior is unchanged.

It works for every `Plot`: the built-in charts and hand-written plots,
since the check sits in `track_appear`, which `PlotElement` already
calls.

## Public API

### `gpui-base`

```rust
pub struct PlotAppearScope                                              // remembers finished appears across remounts
impl PlotAppearScope {
    pub fn new(id: impl Into<ElementId>, child: impl IntoElement) -> Self
}
impl IntoElement for PlotAppearScope
impl Element for PlotAppearScope
```

Re-exported as `gpui_component::plot::PlotAppearScope`.

## Breaking Changes

None.

## Test plan

- [x] `cargo test -p gpui-base -p gpui-component`
- [x] New tests in `plot::appear`:
- A remount inside a scope after the appear finished is complete at once
and asks for no frames.
  - A new generation still replays.
  - A remount before the appear finished replays.
  - A renamed scope, or one that stops being painted, forgets.
  - Without a scope, a remount replays as before.
- [x] `cargo clippy -p gpui-base -p gpui-component --lib --tests --
--deny warnings`
- [x] Docs: `website/base/plot.md`, `website/component/chart.md` (en +
zh-CN), `release-notes.md`

This is a `gpui-base` change: the appear state it extends already lives
there.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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