From bccfdf10c0f063421f6970e79d2bbeb0e6607346 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 28 Sep 2026 21:05:32 +0800 Subject: [PATCH 1/6] chart: Add appear motion to `Plot` and every chart 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 --- crates/base/src/motion.rs | 6 + crates/base/src/plot/appear.rs | 282 ++++++++++++++++++ crates/base/src/plot/element.rs | 31 +- crates/base/src/plot/mod.rs | 60 +++- crates/component/src/chart/area_chart.rs | 61 +++- crates/component/src/chart/bar_chart.rs | 79 ++++- .../component/src/chart/candlestick_chart.rs | 164 ++++++---- crates/component/src/chart/line_chart.rs | 59 +++- crates/component/src/chart/mod.rs | 106 ++++++- crates/component/src/chart/pie_chart.rs | 88 +++++- crates/component/src/chart/radar_chart.rs | 49 ++- crates/component/src/chart/sankey_chart.rs | 165 +++++----- crates/component/src/theme/mod.rs | 12 + website/base/plot.md | 7 +- website/component/chart.md | 24 +- website/zh-CN/base/plot.md | 7 +- website/zh-CN/component/chart.md | 24 +- 17 files changed, 995 insertions(+), 229 deletions(-) create mode 100644 crates/base/src/plot/appear.rs diff --git a/crates/base/src/motion.rs b/crates/base/src/motion.rs index 1ac947cc68..2de123ab49 100644 --- a/crates/base/src/motion.rs +++ b/crates/base/src/motion.rs @@ -143,6 +143,12 @@ impl Transition { self.easing.sample(progress) } + /// The easing curve, for a caller that samples one timeline at several + /// offsets, such as a staggered plot appear. + pub(crate) fn curve(&self) -> &Easing { + &self.easing + } + fn progress(&self, elapsed: Duration, duration: Duration) -> (f32, MotionStatus) { let Some(active_elapsed) = self.delay.active_elapsed(elapsed) else { return (0.0, MotionStatus::Delayed); diff --git a/crates/base/src/plot/appear.rs b/crates/base/src/plot/appear.rs new file mode 100644 index 0000000000..d7c17221ac --- /dev/null +++ b/crates/base/src/plot/appear.rs @@ -0,0 +1,282 @@ +//! The appear motion shared by every [`Plot`](super::Plot): how far its data +//! marks have drawn in since the plot was first painted. +//! +//! This is behavior only. A plot decides what appearing looks like — a line +//! revealed from the left, bars growing from zero — and a styled layer +//! projects the timing through [`PlotMotion`](crate::PlotMotion). +use gpui::{App, ElementId, Window}; + +use crate::{ + Theme, + motion::{Easing, Presence}, +}; + +/// How far a plot's data marks have appeared this frame, handed to +/// [`Plot::appear`](super::Plot::appear). +/// +/// The appear starts on the first frame a plot's id is painted and runs over +/// the active [`PlotMotion`](crate::PlotMotion)'s appear. Base's default +/// duration is zero, and reduced motion skips it, so a plot is then complete +/// from its first frame. +#[derive(Clone)] +pub struct PlotAppear { + /// Linear time through the appear, from `0` to `1`. + time: f32, + easing: Easing, +} + +impl PlotAppear { + /// A finished appear: every mark is complete. + pub fn complete() -> Self { + Self { + time: 1., + easing: Easing::Linear, + } + } + + /// How far the whole plot has appeared, from `0` to `1`, eased. + pub fn progress(&self) -> f32 { + // Charts read this per mark on every frame, long after the appear is + // done, so a finished appear skips sampling the curve. + if self.time >= 1. { + return 1.; + } + self.easing.sample(self.time) + } + + /// Whether the appear is still running. + pub fn is_appearing(&self) -> bool { + self.time < 1. + } + + /// How far mark `index` of `count` has appeared, from `0` to `1`, eased. + /// + /// The marks start one after another across the first `spread` of the + /// appear (`0..1`) and each runs for the rest of it, so the last mark + /// still finishes with the appear however many marks there are. A `spread` + /// of `0` moves every mark together. + pub fn staggered(&self, index: usize, count: usize, spread: f32) -> f32 { + if count <= 1 || self.time >= 1. { + return self.progress(); + } + let spread = spread.clamp(0., 0.95); + let start = spread * index.min(count - 1) as f32 / (count - 1) as f32; + let time = ((self.time - start) / (1. - spread)).clamp(0., 1.); + self.easing.sample(time) + } +} + +/// The element-state key of a plot's appear, within the plot's scope. +const APPEAR: &str = "__plot-appear"; + +/// Sample the appear of the plot painting under the window's current element +/// id. A new `generation` starts it over. Called by +/// [`PlotElement`](super::PlotElement) within the plot's element scope on every +/// frame, so it borrows the theme rather than cloning it and builds its key +/// without allocating. +pub(super) fn track_appear(generation: u64, window: &mut Window, cx: &mut App) -> PlotAppear { + let Some(policy) = cx + .try_global::() + .map(|theme| theme.plot.motion().appear().clone()) + else { + return PlotAppear::complete(); + }; + let easing = policy.curve().clone(); + // Presence keeps the linear time so the marks can each ease over their + // own slice of it; see `PlotAppear::staggered`. + let sample = Presence::new((ElementId::Integer(generation), APPEAR), true) + .transition(policy.easing(Easing::Linear)) + .sample(window, cx); + PlotAppear { + time: sample.progress, + easing, + } +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc, time::Duration}; + + use gpui::{ + Bounds, Context, ElementId, IntoElement, Pixels, Render, TestAppContext, WindowHandle, px, + size, + }; + + use super::*; + use crate::{ + PlotMotion, PlotTheme, + motion::Transition, + plot::{Plot, PlotElement}, + }; + + /// A plot that records the appear progress it is handed each frame. + struct Recorder { + samples: Rc>>, + generation: Option, + } + + impl IntoElement for Recorder { + type Element = PlotElement; + + fn into_element(self) -> Self::Element { + PlotElement::new(self) + } + } + + impl Plot for Recorder { + fn paint(&mut self, _: Bounds, _: &mut Window, _: &mut App) {} + + fn id(&self) -> Option { + Some("recorder".into()) + } + + // Appear motion rides on the id alone, without the interactive layer. + fn interactive(&self) -> bool { + false + } + + fn appear(&mut self, appear: PlotAppear, _: &mut Window, _: &mut App) { + self.samples.borrow_mut().push(appear.progress()); + } + + fn appear_generation(&self) -> Option { + self.generation + } + } + + struct RecorderView { + samples: Rc>>, + generation: Option, + } + + impl Render for RecorderView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Recorder { + samples: self.samples.clone(), + generation: self.generation, + } + } + } + + fn open( + cx: &mut TestAppContext, + generation: Option, + ) -> (WindowHandle, Rc>>) { + cx.update(|cx| { + cx.set_global(Theme::default()); + Theme::global_mut(cx).plot = PlotTheme::new().with_motion( + PlotMotion::default() + .with_appear(Transition::new(Duration::from_millis(100)).ease(|t| t)), + ); + }); + let samples = Rc::new(RefCell::new(Vec::new())); + let window = cx.open_window(size(px(100.), px(100.)), { + let samples = samples.clone(); + move |_, _| RecorderView { + samples, + generation, + } + }); + cx.run_until_parked(); + (window, samples) + } + + fn next_frame(window: WindowHandle, cx: &mut TestAppContext) -> usize { + let frames = window + .update(cx, |_, window, cx| window.simulate_next_frame(cx)) + .unwrap(); + cx.run_until_parked(); + frames + } + + #[gpui::test] + fn test_plot_appears_once_over_the_theme_duration(cx: &mut TestAppContext) { + let (window, samples) = open(cx, Some(0)); + assert_eq!(samples.borrow().last(), Some(&0.)); + + cx.executor().advance_clock(Duration::from_millis(50)); + next_frame(window, cx); + assert_eq!(samples.borrow().last(), Some(&0.5)); + + cx.executor().advance_clock(Duration::from_millis(50)); + next_frame(window, cx); + assert_eq!(samples.borrow().last(), Some(&1.)); + + // Once whole, the plot stops asking for frames and stays whole. + assert_eq!(next_frame(window, cx), 0); + window.update(cx, |_, window, _| window.refresh()).unwrap(); + cx.run_until_parked(); + assert_eq!(samples.borrow().last(), Some(&1.)); + } + + #[gpui::test] + fn test_reduced_motion_skips_the_appear(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); + let (window, samples) = open(cx, Some(0)); + assert_eq!(samples.borrow().first(), Some(&1.)); + assert_eq!(next_frame(window, cx), 0); + } + + /// A plot that does not opt in is whole at once and asks for no frames, + /// even with an appear duration in the theme. + #[gpui::test] + fn test_plot_without_a_generation_does_not_appear(cx: &mut TestAppContext) { + let (window, samples) = open(cx, None); + assert_eq!(samples.borrow().first(), Some(&1.)); + assert_eq!(next_frame(window, cx), 0); + } + + /// A new generation starts the appear over. + #[gpui::test] + fn test_new_generation_replays_the_appear(cx: &mut TestAppContext) { + let (window, samples) = open(cx, Some(0)); + cx.executor().advance_clock(Duration::from_millis(100)); + next_frame(window, cx); + assert_eq!(samples.borrow().last(), Some(&1.)); + + window + .update(cx, |view, _, cx| { + view.generation = Some(1); + cx.notify(); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(samples.borrow().last(), Some(&0.)); + } + + fn at(time: f32) -> PlotAppear { + PlotAppear { + time, + easing: Easing::Linear, + } + } + + #[test] + fn test_complete_appear() { + let appear = PlotAppear::complete(); + assert!(!appear.is_appearing()); + assert_eq!(appear.progress(), 1.); + assert_eq!(appear.staggered(3, 10, 0.5), 1.); + } + + #[test] + fn test_staggered_marks_share_the_appear() { + // The first mark starts at once, the last once the spread has passed. + assert_eq!(at(0.).staggered(0, 5, 0.5), 0.); + assert_eq!(at(0.25).staggered(0, 5, 0.5), 0.5); + assert_eq!(at(0.5).staggered(4, 5, 0.5), 0.); + assert_eq!(at(0.75).staggered(4, 5, 0.5), 0.5); + // Every mark finishes with the appear. + for index in 0..5 { + assert_eq!(at(1.).staggered(index, 5, 0.5), 1.); + } + } + + #[test] + fn test_staggered_without_spread_moves_together() { + assert_eq!(at(0.4).staggered(0, 3, 0.), 0.4); + assert_eq!(at(0.4).staggered(2, 3, 0.), 0.4); + // A lone mark ignores the spread. + assert_eq!(at(0.4).staggered(0, 1, 0.5), 0.4); + } +} diff --git a/crates/base/src/plot/element.rs b/crates/base/src/plot/element.rs index 9e8939d7d3..893acbbb4f 100644 --- a/crates/base/src/plot/element.rs +++ b/crates/base/src/plot/element.rs @@ -1,5 +1,5 @@ -//! The element behind every [`Plot`]: layout, hover tracking and the overlay -//! the plot returns from [`Plot::tooltip`]. +//! The element behind every [`Plot`]: layout, appear and hover tracking and +//! the overlay the plot returns from [`Plot::tooltip`]. use std::{cell::Cell, rc::Rc}; use gpui::{ @@ -8,10 +8,11 @@ use gpui::{ Style, TouchPhase, Window, }; -use super::{Plot, hover::track_hover}; +use super::{Plot, PlotAppear, appear::track_appear, hover::track_hover}; -/// Paints a [`Plot`] filling its container, with hover tracking and the plot's -/// tooltip overlay when the plot has an [`Plot::id`]. +/// Paints a [`Plot`] filling its container, with appear tracking when the plot +/// has an [`Plot::id`], and hover tracking and the plot's tooltip overlay when +/// it is also [`Plot::interactive`]. /// /// A plot becomes an element through this type: /// @@ -60,7 +61,8 @@ impl Element for PlotElement

{ type PrepaintState = (Option, Vec, Option); fn id(&self) -> Option { - // `Some` opts the plot in to interactive tooltips. + // `Some` gives the plot element state: appear motion and, when + // interactive, tooltips. self.0.id() } @@ -99,6 +101,17 @@ impl Element for PlotElement

{ return (None, children, None); }; + let appear = match self.0.appear_generation() { + Some(generation) => track_appear(generation, window, cx), + None => PlotAppear::complete(), + }; + let appearing = appear.is_appearing(); + self.0.appear(appear, window, cx); + + if !self.0.interactive() { + return (None, children, None); + } + // `Hitbox::is_hovered` is false while an open popup or modal covers the plot. let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); @@ -109,7 +122,11 @@ impl Element for PlotElement

{ .map(|_| window.mouse_position()) .filter(|mouse| bounds.contains(mouse)) .map(|mouse| mouse - bounds.origin); - let live = cursor.and_then(|position| self.0.tooltip_state(position, bounds, cx)); + // No tooltip while the marks draw in: its dots would land on data not + // painted yet. + let live = cursor + .filter(|_| !appearing) + .and_then(|position| self.0.tooltip_state(position, bounds, cx)); // The datum under the cursor, or the last one while its hover fades out. let hover = track_hover(live, cursor, window, cx); diff --git a/crates/base/src/plot/mod.rs b/crates/base/src/plot/mod.rs index d1e7c58288..f764c1a069 100644 --- a/crates/base/src/plot/mod.rs +++ b/crates/base/src/plot/mod.rs @@ -2,7 +2,9 @@ //! hover tracking behind every [`Plot`]. //! //! Colors are always handed in by the caller. A styled layer supplies chart -//! defaults, the tooltip overlay, and hover timing through [`PlotMotion`]. +//! defaults, the tooltip overlay, and hover and appear timing through +//! [`PlotMotion`]. +mod appear; mod axis; mod element; mod grid; @@ -21,6 +23,7 @@ use gpui::{ use crate::{Spring, motion::Transition}; +pub use appear::PlotAppear; #[allow(deprecated)] pub use axis::AXIS_GAP; pub use axis::{AxisLabelPlacement, AxisLabelSide, AxisText, PlotAxis, axis_gutter}; @@ -31,8 +34,9 @@ pub use label::PlotLabel; pub use path_cache::{PathCache, PathCaches, ShapeKey}; pub use scale::PlotValue; -/// The timing of a plot's hover: how its progress fades in and out, and the -/// spring a pointer follows the hovered datum with. +/// The timing of a plot's motion: how its data marks appear when it is first +/// painted, how its hover progress fades in and out, and the spring a pointer +/// follows the hovered datum with. /// /// Base installs no motion of its own: every duration defaults to zero, so the /// hover appears, fades and glides at once. Product timing belongs to the @@ -42,6 +46,7 @@ pub struct PlotMotion { pointer: Spring, enter: Transition, exit: Transition, + appear: Transition, } impl Default for PlotMotion { @@ -50,6 +55,7 @@ impl Default for PlotMotion { pointer: Spring::new(Duration::ZERO), enter: Transition::new(Duration::ZERO), exit: Transition::new(Duration::ZERO), + appear: Transition::new(Duration::ZERO), } } } @@ -74,6 +80,13 @@ impl PlotMotion { self } + /// How a plot's data marks appear the first time it is painted; see + /// [`PlotAppear`]. + pub fn with_appear(mut self, appear: Transition) -> Self { + self.appear = appear; + self + } + pub fn pointer(&self) -> Spring { self.pointer } @@ -85,6 +98,10 @@ impl PlotMotion { pub fn exit(&self) -> &Transition { &self.exit } + + pub fn appear(&self) -> &Transition { + &self.appear + } } pub trait Plot: IntoElement { @@ -110,11 +127,13 @@ pub trait Plot: IntoElement { fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App); - /// A stable element id that enables interactive tooltip support for this plot. + /// A stable element id that keeps this plot's state across frames. /// - /// Return `Some(id)` to opt in to tooltips and hover motion; the id must be unique - /// among sibling elements. Returning `None` (the default for a hand-written plot) - /// disables all tooltip behavior, leaving the plot a pure, non-interactive element. + /// Return `Some(id)` to opt in to appear motion and, unless + /// [`Plot::interactive`] says otherwise, tooltips and hover motion; the id + /// must be unique among sibling elements. Returning `None` (the default for + /// a hand-written plot) leaves the plot a pure element that neither appears + /// nor tracks hover. /// /// The charts in GPUI Component always return `Some`: their id defaults to the /// source location they were constructed at, and `id` renames it. @@ -122,6 +141,33 @@ pub trait Plot: IntoElement { None } + /// Whether a plot with an [`Plot::id`] tracks hover and shows its tooltip. + /// + /// `false` keeps the id's state — appear motion and path caches — without + /// the hitbox, hover tracking or overlay. The default is `true`, so a plot + /// opts in to tooltips by returning an id. + fn interactive(&self) -> bool { + true + } + + /// Receive how far the plot's data marks have appeared this frame, before + /// [`Plot::hover`] and [`Plot::paint`] run. + /// + /// Called on every frame the plot has an [`Plot::id`]; see [`PlotAppear`]. + /// Without an [`Plot::appear_generation`] the appear is always complete. + /// The default ignores it. + fn appear(&mut self, _appear: PlotAppear, _window: &mut Window, _cx: &mut App) {} + + /// Opt in to appear motion: `Some` draws the plot in the first time its + /// id is painted, and again whenever the value changes, such as when a + /// chart switches to another symbol or period. + /// + /// The default, `None`, tracks no appear, keeps no state for it and asks + /// for no frames, so a plot that does not draw in costs nothing. + fn appear_generation(&self) -> Option { + None + } + /// Map the cursor to the tooltip state to display. /// /// `position` is the cursor position relative to the plot's top-left origin (already diff --git a/crates/component/src/chart/area_chart.rs b/crates/component/src/chart/area_chart.rs index bffde5044e..26128ed364 100644 --- a/crates/component/src/chart/area_chart.rs +++ b/crates/component/src/chart/area_chart.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{hash::Hash, rc::Rc}; use gpui::{ AnyElement, App, Background, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, @@ -9,7 +9,7 @@ use gpui_component_macros::IntoPlot; use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, Curve, PathCaches, Plot, PlotAxis, + AxisLabelPlacement, Curve, PathCaches, Plot, PlotAppear, PlotAxis, scale::{PlotValue, Scale, ScaleLinear, ScalePoint}, shape::Area, tooltip::{CrossLine, Dot, Tooltip, TooltipState}, @@ -17,9 +17,9 @@ use crate::{ }; use super::{ - AXIS_GAP, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, + AXIS_GAP, ChartAppear, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask, - point_range, point_value_scale, + point_range, point_value_scale, reveal_mask, }; #[derive(IntoPlot)] @@ -45,6 +45,7 @@ where axes: PointAxes, id: ElementId, interactive: bool, + appear: ChartAppear, } impl AreaChart @@ -74,6 +75,7 @@ where axes: PointAxes::default(), id: caller_id(), interactive: true, + appear: ChartAppear::default(), } } @@ -94,13 +96,32 @@ where /// and a dot per series mark the hovered point, and a tooltip shows a row /// each. Turn it off for a chart that only decorates, or one an element above /// it wants the cursor for: without a hitbox it neither answers the mouse nor - /// takes the hover from what sits over it. A chart that is off also drops its - /// path cache, which is keyed on the same id. + /// takes the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the name of the most recently added series, shown in its tooltip row. /// /// Call after the matching [`AreaChart::y`] (e.g. `.y(..).stroke(..).name("Desktop")`). @@ -458,11 +479,11 @@ where .y_domain .is_some() .then(|| pinned_plot_mask(bounds, height)); + // The areas draw in from the left under a mask, so their shapes, and + // the cached paths, stay the same on every frame of the appear. + let reveal = reveal_mask(bounds, left, self.appear.get().progress()); window.with_content_mask(mask, |window| { - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off tessellates afresh each paint. - if self.interactive { + window.with_content_mask(reveal, |window| { let caches = PathCaches::for_paint("areas", window, cx); caches.update(cx, |caches, _| { for (i, area) in areas.enumerate() { @@ -470,11 +491,7 @@ where area.paint_cached(&bounds, fill, line, window); } }); - } else { - for area in areas { - area.paint(&bounds, window); - } - } + }); }); self.axes @@ -483,7 +500,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/bar_chart.rs b/crates/component/src/chart/bar_chart.rs index c9e96d2f8f..cf4ef698f8 100644 --- a/crates/component/src/chart/bar_chart.rs +++ b/crates/component/src/chart/bar_chart.rs @@ -9,7 +9,7 @@ use gpui_component_macros::IntoPlot; use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel, + AxisLabelPlacement, AxisLabelSide, AxisText, Grid, Plot, PlotAppear, PlotAxis, PlotLabel, label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width}, scale::{PlotValue, Scale, ScaleBand, ScaleLinear}, shape::{Bar, BarAlignment}, @@ -18,8 +18,8 @@ use crate::{ }; use super::{ - AXIS_GAP, MAX_BAND_WIDTH, TickFormat, TooltipContent, VALUE_AXIS_GAP, build_band_labels, - caller_id, format_tick, labeled_items, value_axis_gap, + AXIS_GAP, ChartAppear, MAX_BAND_WIDTH, TickFormat, TooltipContent, VALUE_AXIS_GAP, + build_band_labels, caller_id, format_tick, labeled_items, value_axis_gap, }; /// How much the bars away from the hovered one fade, as a share of their opacity. @@ -68,6 +68,7 @@ where min_length: f32, id: ElementId, interactive: bool, + appear: ChartAppear, name: Option, tooltip_content: TooltipContent, /// The label gaps of horizontal bars, measured in `prepaint` for the frame, @@ -115,6 +116,7 @@ where min_length: 0., id: caller_id(), interactive: true, + appear: ChartAppear::default(), name: None, tooltip_content: TooltipContent::default(), horizontal_gaps: (0., 0.), @@ -140,13 +142,32 @@ where /// marks the hovered band, and a tooltip shows its category and value. Turn /// it off for a chart that only decorates, or one an element above it wants /// the cursor for: without a hitbox it neither answers the mouse nor takes - /// the hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); @@ -963,34 +984,42 @@ where 1. - HOVER_DIM * hover.focus * distance }; + // Each bar grows out of the zero line as the chart appears, one after + // another across the first half of the appear. + let appear = self.appear.get().clone(); + let count = self.data.len(); + let label_appear = appear.clone(); + let mut bar = Bar::new() - .data(&self.data) + .data(self.data.iter().enumerate()) .alignment(alignment) .band_width(band_width) - .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset)) + .cross(move |(_, d)| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset)) .base(move |_| zero_pixel) - .value(move |d| { - bar_end( + .value(move |(ix, d)| { + let end = bar_end( &value_scale, value_fn_cloned(d), zero_pixel, alignment, min_length, - ) + )?; + let grown = appear.staggered(*ix, count, APPEAR_SPREAD); + Some(zero_pixel + (end - zero_pixel) * grown) }) .corner_radii(self.corner_radii); bar = match (fill, fill_gradient) { (_, Some(fg)) => { let value_fn_for_grad = value_fn.clone(); - bar.fill(move |d, frame, alignment| { + bar.fill(move |(_, d), frame, alignment| { let v = value_fn_for_grad(d).to_f32().unwrap_or(0.); let [s0, s1] = bar_gradient(fg.as_ref(), d, v, chart_range.clone()); let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1); bg.opacity(emphasis(frame)) }) } - (Some(f), _) => bar.fill(move |d, frame, alignment| { + (Some(f), _) => bar.fill(move |(_, d), frame, alignment| { f(d, frame, chart_bounds, alignment).opacity(emphasis(frame)) }), _ => bar.fill(move |_, frame, _| default_fill.opacity(emphasis(frame))), @@ -1003,8 +1032,12 @@ where BarAlignment::Left => TextAlign::Left, BarAlignment::Right => TextAlign::Right, }; - bar = bar.label(move |d, p| { - let color = label_color_fn.as_ref().map_or(label_color, |f| f(d)); + bar = bar.label(move |(ix, d), p| { + // A value label rides the end of its bar and fades in with it. + let color = label_color_fn + .as_ref() + .map_or(label_color, |f| f(d)) + .opacity(label_appear.staggered(*ix, count, APPEAR_SPREAD)); vec![Text::new(label(d), p, color).align(text_align)] }); } @@ -1016,7 +1049,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( @@ -1135,6 +1180,10 @@ where } } +/// How much of the appear the bars' starts are spread across; each bar grows +/// over the rest. +const APPEAR_SPREAD: f32 = 0.5; + /// The end a bar showing `value` reaches along the value axis, at least /// `min_length` pixels from `zero`. fn bar_end( diff --git a/crates/component/src/chart/candlestick_chart.rs b/crates/component/src/chart/candlestick_chart.rs index acce514bd8..0b64671569 100644 --- a/crates/component/src/chart/candlestick_chart.rs +++ b/crates/component/src/chart/candlestick_chart.rs @@ -10,14 +10,15 @@ use rust_i18n::t; use crate::{ ActiveTheme, plot::{ - Grid, Plot, PlotAxis, origin_point, + Grid, Plot, PlotAppear, PlotAxis, origin_point, scale::{PlotValue, Scale, ScaleBand, ScaleLinear}, tooltip::{CrossLine, Tooltip, TooltipState}, }, }; use super::{ - AXIS_GAP, MAX_BAND_WIDTH, TooltipContent, build_band_labels, caller_id, labeled_items, + AXIS_GAP, ChartAppear, MAX_BAND_WIDTH, TooltipContent, build_band_labels, caller_id, + labeled_items, reveal_mask, }; #[derive(IntoPlot)] @@ -42,6 +43,7 @@ where bearish: Option, id: ElementId, interactive: bool, + appear: ChartAppear, tooltip_content: TooltipContent, } @@ -71,6 +73,7 @@ where bearish: None, id: caller_id(), interactive: true, + appear: ChartAppear::default(), tooltip_content: TooltipContent::default(), } } @@ -92,13 +95,32 @@ where /// band marks the hovered candle, and a tooltip shows its open, high, low and /// close. Turn it off for a chart that only decorates, or one an element /// above it wants the cursor for: without a hitbox it neither answers the - /// mouse nor takes the hover from what sits over it. A chart that is off also - /// drops its path cache, which is keyed on the same id. + /// mouse nor takes the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the hover tooltip's title for a datum, instead of its x value. pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self { self.tooltip_content.set_title(title); @@ -315,68 +337,84 @@ where let low_fn = low_fn.clone(); let close_fn = close_fn.clone(); - for d in &self.data { - let x_tick = x.tick(&x_fn(d)); - let Some(x_tick) = x_tick else { - continue; - }; - - // Get OHLC values for the current data point - let open = open_fn(d); - let high = high_fn(d); - let low = low_fn(d); - let close = close_fn(d); - - // Convert values to pixel coordinates - let open_y = y.tick(&open); - let high_y = y.tick(&high); - let low_y = y.tick(&low); - let close_y = y.tick(&close); - - let (Some(open_y), Some(high_y), Some(low_y), Some(close_y)) = - (open_y, high_y, low_y, close_y) - else { - continue; - }; - - // Determine if bullish (close > open) or bearish (close < open) - let is_bullish = close > open; - let color = if is_bullish { bullish } else { bearish }; - - // Calculate candlestick body dimensions - let center_x = x_tick + band_width / 2.; - let body_width = band_width * self.body_width_ratio; - let body_left = center_x - body_width / 2.; - let body_right = center_x + body_width / 2.; - - // Draw wick (high to low line): a 1px quad, so no stroke to tessellate. - let (wick_top, wick_bottom) = (high_y.min(low_y), high_y.max(low_y)); - let wick_bounds = Bounds::from_corners( - origin_point(px(center_x - 0.5), px(wick_top), origin), - origin_point(px(center_x + 0.5), px(wick_bottom), origin), - ); - window.paint_quad(fill(wick_bounds, color)); - - // Draw body (open to close rectangle) - // For bullish: top is close, bottom is open - // For bearish: top is open, bottom is close - let (top, bottom) = if is_bullish { - (close_y, open_y) - } else { - (open_y, close_y) - }; - - let body_bounds = Bounds::from_corners( - origin_point(px(body_left), px(top), origin), - origin_point(px(body_right), px(bottom), origin), - ); - - window.paint_quad(fill(body_bounds, color)); - } + // The candles draw in from the left under a mask. + let reveal = reveal_mask(bounds, 0., self.appear.get().progress()); + window.with_content_mask(reveal, |window| { + for d in &self.data { + let x_tick = x.tick(&x_fn(d)); + let Some(x_tick) = x_tick else { + continue; + }; + + // Get OHLC values for the current data point + let open = open_fn(d); + let high = high_fn(d); + let low = low_fn(d); + let close = close_fn(d); + + // Convert values to pixel coordinates + let open_y = y.tick(&open); + let high_y = y.tick(&high); + let low_y = y.tick(&low); + let close_y = y.tick(&close); + + let (Some(open_y), Some(high_y), Some(low_y), Some(close_y)) = + (open_y, high_y, low_y, close_y) + else { + continue; + }; + + // Determine if bullish (close > open) or bearish (close < open) + let is_bullish = close > open; + let color = if is_bullish { bullish } else { bearish }; + + // Calculate candlestick body dimensions + let center_x = x_tick + band_width / 2.; + let body_width = band_width * self.body_width_ratio; + let body_left = center_x - body_width / 2.; + let body_right = center_x + body_width / 2.; + + // Draw wick (high to low line): a 1px quad, so no stroke to tessellate. + let (wick_top, wick_bottom) = (high_y.min(low_y), high_y.max(low_y)); + let wick_bounds = Bounds::from_corners( + origin_point(px(center_x - 0.5), px(wick_top), origin), + origin_point(px(center_x + 0.5), px(wick_bottom), origin), + ); + window.paint_quad(fill(wick_bounds, color)); + + // Draw body (open to close rectangle) + // For bullish: top is close, bottom is open + // For bearish: top is open, bottom is close + let (top, bottom) = if is_bullish { + (close_y, open_y) + } else { + (open_y, close_y) + }; + + let body_bounds = Bounds::from_corners( + origin_point(px(body_left), px(top), origin), + origin_point(px(body_right), px(bottom), origin), + ); + + window.paint_quad(fill(body_bounds, color)); + } + }); } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/line_chart.rs b/crates/component/src/chart/line_chart.rs index 8d8096824f..59b965648f 100644 --- a/crates/component/src/chart/line_chart.rs +++ b/crates/component/src/chart/line_chart.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{hash::Hash, rc::Rc}; use gpui::{ AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Size, @@ -9,7 +9,7 @@ use gpui_component_macros::IntoPlot; use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, Curve, PathCaches, Plot, PlotAxis, + AxisLabelPlacement, Curve, PathCaches, Plot, PlotAppear, PlotAxis, scale::{PlotValue, Scale, ScaleLinear, ScalePoint}, shape::Line, tooltip::{CrossLine, Dot, Tooltip, TooltipState}, @@ -17,9 +17,9 @@ use crate::{ }; use super::{ - AXIS_GAP, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, + AXIS_GAP, ChartAppear, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask, - point_range, point_value_scale, + point_range, point_value_scale, reveal_mask, }; #[derive(IntoPlot)] @@ -43,6 +43,7 @@ where axes: PointAxes, id: ElementId, interactive: bool, + appear: ChartAppear, name: Option, tooltip_content: TooltipContent, } @@ -72,6 +73,7 @@ where axes: PointAxes::default(), id: caller_id(), interactive: true, + appear: ChartAppear::default(), name: None, tooltip_content: TooltipContent::default(), } @@ -94,13 +96,32 @@ where /// and a dot mark the hovered point, and a tooltip shows its value. Turn it /// off for a chart that only decorates, or one an element above it wants the /// cursor for: without a hitbox it neither answers the mouse nor takes the - /// hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); @@ -439,18 +460,16 @@ where .y_domain .is_some() .then(|| pinned_plot_mask(bounds, height)); + // The line draws in from the left under a mask, so its shape, and the + // cached path, stay the same on every frame of the appear. + let reveal = reveal_mask(bounds, left, self.appear.get().progress()); window.with_content_mask(mask, |window| { - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off tessellates afresh each paint. - if self.interactive { + window.with_content_mask(reveal, |window| { let caches = PathCaches::for_paint("line", window, cx); caches.update(cx, |caches, _| { line.paint_cached(&bounds, caches.slot(0), window); }); - } else { - line.paint(&bounds, window); - } + }); }); self.axes @@ -459,7 +478,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/mod.rs b/crates/component/src/chart/mod.rs index 54fbf3464e..97a81ac4c5 100644 --- a/crates/component/src/chart/mod.rs +++ b/crates/component/src/chart/mod.rs @@ -14,7 +14,11 @@ pub use pie_chart::PieChart; pub use radar_chart::{RadarChart, RadarLabel}; pub use sankey_chart::{SankeyChart, SankeyLabel}; -use std::{hash::Hash, panic::Location, rc::Rc}; +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + panic::Location, + rc::Rc, +}; use gpui::{ AnyElement, App, Bounds, ContentMask, ElementId, Hsla, IntoElement, ParentElement as _, Pixels, @@ -24,7 +28,7 @@ use gpui::{ use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, AxisText, Grid, PlotLabel, + AxisLabelPlacement, AxisText, Grid, PlotAppear, PlotLabel, label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width}, scale::{PlotValue, Scale, ScaleBand, ScaleLinear, ScalePoint}, tooltip::Tooltip, @@ -50,6 +54,76 @@ pub(crate) fn caller_id() -> ElementId { ElementId::CodeLocation(*Location::caller()) } +/// A chart's appear: whether its data draws in the first time it is painted, +/// the key that replays it, and how far it has drawn in this frame. +pub(crate) struct ChartAppear { + enabled: bool, + generation: u64, + current: PlotAppear, +} + +impl Default for ChartAppear { + fn default() -> Self { + Self { + enabled: true, + generation: 0, + current: PlotAppear::complete(), + } + } +} + +impl ChartAppear { + pub(crate) fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + pub(crate) fn set_key(&mut self, key: impl Hash) { + let mut hasher = DefaultHasher::new(); + key.hash(&mut hasher); + self.generation = hasher.finish(); + } + + /// The generation a chart hands [`Plot::appear_generation`], or `None` + /// when it opted out, so no appear is tracked and no frames are asked for. + /// + /// [`Plot::appear_generation`]: crate::plot::Plot::appear_generation + pub(crate) fn generation(&self) -> Option { + self.enabled.then_some(self.generation) + } + + pub(crate) fn update(&mut self, appear: PlotAppear) { + self.current = appear; + } + + pub(crate) fn get(&self) -> &PlotAppear { + &self.current + } +} + +/// The mask a chart that draws in from the left paints its series under while +/// it appears: everything left of `progress` of the way across the plot, which +/// starts `left` into `bounds`. It bleeds by half a hover dot, so a dot on the +/// plot's first point shows whole as soon as the reveal passes it, and there +/// is no mask once the appear is done. +pub(crate) fn reveal_mask( + bounds: Bounds, + left: f32, + progress: f32, +) -> Option> { + if progress >= 1. { + return None; + } + let bleed = HOVER_DOT_SIZE / 2.; + let start = bounds.left() + px(left) - bleed; + let end = start + (bounds.right() + bleed - start) * progress.max(0.); + Some(ContentMask { + bounds: Bounds::from_corners( + gpui::point(bounds.left() - bleed, bounds.top() - bleed), + gpui::point(end, bounds.bottom() + bleed), + ), + }) +} + /// The size of the dot marking the hovered data point. pub(crate) const HOVER_DOT_SIZE: Pixels = px(8.); @@ -610,12 +684,30 @@ mod tests { assert_eq!(Plot::id(&chart()), Plot::id(&chart())); } - /// The escape hatch: no id means no hitbox, so nothing above the chart has - /// to fight it for the cursor. + /// The escape hatch: a chart turned off has no hitbox, so nothing above it + /// has to fight it for the cursor, but it keeps its id for its appear and + /// its caches. + #[test] + fn a_chart_turned_off_keeps_its_id_but_not_its_hitbox() { + let off = chart().interactive(false); + assert!(!Plot::interactive(&off)); + assert!(Plot::id(&off).is_some()); + assert_eq!( + Plot::id(&chart().id("pie").interactive(false)), + Some("pie".into()) + ); + } + + /// Without a key a chart appears once; a key names the generation that + /// replays it. #[test] - fn a_chart_turned_off_has_no_id_to_key_anything_on() { - assert!(Plot::id(&chart().interactive(false)).is_none()); - assert!(Plot::id(&chart().id("pie").interactive(false)).is_none()); + fn an_appear_key_replays_the_appear() { + assert_eq!(Plot::appear_generation(&chart()), Some(0)); + assert_eq!(Plot::appear_generation(&chart().appear(false)), None); + let a = Plot::appear_generation(&chart().appear_key("AAPL.US")); + let b = Plot::appear_generation(&chart().appear_key("TSLA.US")); + assert_ne!(a, b); + assert_eq!(a, Plot::appear_generation(&chart().appear_key("AAPL.US"))); } /// Only a label on the axis's last point hugs the right edge; the last item diff --git a/crates/component/src/chart/pie_chart.rs b/crates/component/src/chart/pie_chart.rs index ffde4c82a6..07af7f63b8 100644 --- a/crates/component/src/chart/pie_chart.rs +++ b/crates/component/src/chart/pie_chart.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{hash::Hash, rc::Rc}; use gpui::{ AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, TextAlign, @@ -8,11 +8,11 @@ use gpui_base::motion::spring; use gpui_component_macros::IntoPlot; use num_traits::Zero; -use super::caller_id; +use super::{ChartAppear, caller_id}; use crate::{ ActiveTheme, plot::{ - PathCaches, Plot, + PathCaches, Plot, PlotAppear, label::{PlotLabel, TEXT_HEIGHT, TEXT_SIZE, Text}, polygon, shape::{Arc, ArcData, Pie}, @@ -29,6 +29,9 @@ const HOVER_LIFT: f32 = 6.; /// How much the slices other than the hovered one fade, as a share of their opacity. const HOVER_DIM: f32 = 0.35; +/// How far into the appear the leader-line labels start fading in. +const LABEL_APPEAR_START: f32 = 0.7; + /// The hover a pie chart paints, sampled once per frame in [`Plot::hover`]. struct PieHover { /// How far each datum's slice has lifted, `0..=1`, springing up on the @@ -56,6 +59,7 @@ pub struct PieChart { tooltip_value: Option SharedString + 'static>>, id: ElementId, interactive: bool, + appear: ChartAppear, name: Option, hover: Option, } @@ -83,6 +87,7 @@ impl PieChart { tooltip_value: None, id: caller_id(), interactive: true, + appear: ChartAppear::default(), name: None, hover: None, } @@ -105,13 +110,32 @@ impl PieChart { /// slice lifts out of the ring, and a tooltip shows its value and share. Turn /// it off for a chart that only decorates, or one an element above it wants /// the cursor for: without a hitbox it neither answers the mouse nor takes - /// the hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); @@ -291,13 +315,25 @@ impl Plot for PieChart { let outer_radius = self.resolve_outer_radius(&bounds); let arcs = self.arcs(); - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off tessellates afresh each paint. - let caches = self - .interactive - .then(|| PathCaches::for_paint("slices", window, cx)); - for (ix, a) in arcs.iter().enumerate() { + // The ring sweeps clockwise from its first slice as the chart appears. + // Every frame of the sweep is a new shape, so the slices tessellate + // afresh until it ends rather than churn the cache. + let appear = self.appear.get().progress(); + let swept; + let slices = if appear < 1. { + let mut arcs = self.arcs(); + let start = arcs.first().map_or(0., |a| a.start_angle); + for a in &mut arcs { + a.start_angle = start + (a.start_angle - start) * appear; + a.end_angle = start + (a.end_angle - start) * appear; + } + swept = arcs; + &swept + } else { + &arcs + }; + let caches = (appear >= 1.).then(|| PathCaches::for_paint("slices", window, cx)); + for (ix, a) in slices.iter().enumerate() { let inner_radius = self.get_inner_radius(a); // The hovered slice lifts out of the ring while the others fade behind it. let (lift, opacity) = self.slice_emphasis(a.index); @@ -326,7 +362,16 @@ impl Plot for PieChart { .inner_radius(label_radius) .outer_radius(label_radius); - let label_color = self.label_color.unwrap_or(cx.theme().foreground); + // Labels fade in over the end of the sweep, once their slices are + // mostly drawn. + let label_opacity = ((appear - LABEL_APPEAR_START) / (1. - LABEL_APPEAR_START)).max(0.); + if label_opacity <= 0. { + return; + } + let label_color = self + .label_color + .unwrap_or(cx.theme().foreground) + .opacity(label_opacity); let default_line_color = cx.theme().border; // First pass: collect a layout candidate per visible slice, split by @@ -355,7 +400,8 @@ impl Plot for PieChart { .label_line_color .as_ref() .map(|f| f(a.data)) - .unwrap_or(default_line_color); + .unwrap_or(default_line_color) + .opacity(label_opacity); let layout = LabelLayout { arc_x: edge.x, @@ -408,7 +454,19 @@ impl Plot for PieChart { } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/radar_chart.rs b/crates/component/src/chart/radar_chart.rs index aa05a2f2d7..2253e6f5a1 100644 --- a/crates/component/src/chart/radar_chart.rs +++ b/crates/component/src/chart/radar_chart.rs @@ -1,5 +1,6 @@ use std::{ f32::consts::{PI, TAU}, + hash::Hash, rc::Rc, }; @@ -13,7 +14,7 @@ use num_traits::Zero; use crate::{ ActiveTheme, plot::{ - Plot, + Plot, PlotAppear, label::{PlotLabel, TEXT_SIZE, Text}, polygon, scale::{PlotValue, Scale, ScaleLinear}, @@ -22,7 +23,7 @@ use crate::{ }, }; -use super::{HOVER_DOT_SIZE, HOVER_HALO_SIZE, TooltipContent, caller_id}; +use super::{ChartAppear, HOVER_DOT_SIZE, HOVER_HALO_SIZE, TooltipContent, caller_id}; const HALF_PI: f32 = PI / 2.; @@ -98,6 +99,7 @@ where dot: bool, id: ElementId, interactive: bool, + appear: ChartAppear, } impl RadarChart @@ -127,6 +129,7 @@ where dot: false, id: caller_id(), interactive: true, + appear: ChartAppear::default(), } } @@ -147,13 +150,32 @@ where /// series marks the hovered dimension, and a tooltip shows a row each. Turn /// it off for a chart that only decorates, or one an element above it wants /// the cursor for: without a hitbox it neither answers the mouse nor takes - /// the hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the name of the most recently added series, shown in its tooltip row. /// /// Call after the matching [`RadarChart::value`] @@ -510,7 +532,8 @@ where } } - // Draw series + // Draw series. They grow out of the center as the chart appears. + let appear = self.appear.get().progress(); for (i, value_fn) in self.values.iter().enumerate() { let stroke = self.series_stroke(i, cx); let fill = self @@ -524,7 +547,7 @@ where let mut line = RadialLine::new() .data(&self.data) .angle(move |_, i| Some(i as f32 * angle_step)) - .radius(move |d, _| scale.tick(&value_fn(d))) + .radius(move |d, _| scale.tick(&value_fn(d)).map(|r| r * appear)) .closed() .fill(fill) .stroke(stroke) @@ -571,7 +594,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/sankey_chart.rs b/crates/component/src/chart/sankey_chart.rs index 03adfb9bb5..ef100a75b4 100644 --- a/crates/component/src/chart/sankey_chart.rs +++ b/crates/component/src/chart/sankey_chart.rs @@ -9,11 +9,11 @@ use gpui::{ }; use gpui_component_macros::IntoPlot; -use super::caller_id; +use super::{ChartAppear, caller_id, reveal_mask}; use crate::{ ActiveTheme, plot::{ - PathCaches, Plot, ShapeKey, + PathCaches, Plot, PlotAppear, ShapeKey, label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width, truncate_text_to_width}, origin_point, shape::{ @@ -133,6 +133,7 @@ pub struct SankeyChart { tooltip_value: Option SharedString + 'static>>, id: ElementId, interactive: bool, + appear: ChartAppear, /// The placement for this frame, resolved in `prepaint` (measuring labels /// needs the window) and read by `tooltip_state` and `paint`. frame: Option>, @@ -168,6 +169,7 @@ impl SankeyChart { tooltip_value: None, id: caller_id(), interactive: true, + appear: ChartAppear::default(), frame: None, hover: None, } @@ -190,13 +192,32 @@ impl SankeyChart { /// node's links stand out from the rest, and a tooltip shows its label and /// throughput. Turn it off for a chart that only decorates, or one an element /// above it wants the cursor for: without a hitbox it neither answers the - /// mouse nor takes the hover from what sits over it. A chart that is off also - /// drops its path cache, which is keyed on the same id. + /// mouse nor takes the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the node rectangle width. Defaults to 10. pub fn node_width(mut self, node_width: f32) -> Self { self.node_width = node_width; @@ -523,26 +544,18 @@ impl Plot for SankeyChart { let node_labels = self.node_labels(cx); - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off places itself afresh each paint. - self.frame = if self.interactive { - let key = self.frame_key(bounds, &node_labels); - let cache = - window.use_keyed_state("sankey-frame", cx, |_, _| SankeyFrameCache::default()); - let cached = cache.read(cx); - if cached.key == Some(key) { - cached.frame.clone() - } else { - let frame = self.place(bounds, node_labels, window).map(Rc::new); - cache.update(cx, |cache, _| { - cache.key = Some(key); - cache.frame = frame.clone(); - }); - frame - } + let key = self.frame_key(bounds, &node_labels); + let cache = window.use_keyed_state("sankey-frame", cx, |_, _| SankeyFrameCache::default()); + let cached = cache.read(cx); + self.frame = if cached.key == Some(key) { + cached.frame.clone() } else { - self.place(bounds, node_labels, window).map(Rc::new) + let frame = self.place(bounds, node_labels, window).map(Rc::new); + cache.update(cx, |cache, _| { + cache.key = Some(key); + cache.frame = frame.clone(); + }); + frame }; vec![] @@ -583,22 +596,23 @@ impl Plot for SankeyChart { // Links first, under the nodes. The links of the hovered node keep their // opacity while the rest fade behind them. // - // Hovering changes only a ribbon's opacity, so an interactive chart keeps - // each tessellated ribbon, slotted by the link's index in the graph so a - // skipped zero-value link doesn't shift the others. Without an id, siblings - // would share the slots, so a chart that is off tessellates afresh. + // Hovering changes only a ribbon's opacity, so the chart keeps each + // tessellated ribbon, slotted by the link's index in the graph so a + // skipped zero-value link doesn't shift the others. + // + // Links and nodes draw in from the left under a mask as the chart + // appears, which leaves the cached ribbons whole; the labels fade in. let min_width = self.min_link_width; - let caches = self - .interactive - .then(|| PathCaches::for_paint("links", window, cx)); - for (ix, link) in graph.links.iter().enumerate() { - if link.value <= 0. { - continue; - } - let source = &graph.nodes[link.source]; - let target = &graph.nodes[link.target]; - let path = match caches.as_ref() { - Some(caches) => caches.update(cx, |caches, _| { + let caches = PathCaches::for_paint("links", window, cx); + let appear = self.appear.get().progress(); + window.with_content_mask(reveal_mask(bounds, 0., appear), |window| { + for (ix, link) in graph.links.iter().enumerate() { + if link.value <= 0. { + continue; + } + let source = &graph.nodes[link.source]; + let target = &graph.nodes[link.target]; + let path = caches.update(cx, |caches, _| { let key = ShapeKey::new(()) .f32(source.x1) .f32(target.x0) @@ -610,37 +624,36 @@ impl Plot for SankeyChart { caches.slot(ix).get(key, bounds.origin, || { sankey_link_path(source, target, link, min_width, Point::default()) }) - }), - None => sankey_link_path(source, target, link, min_width, bounds.origin), - }; - let Some(path) = path else { - continue; - }; - let opacity = match self.hover { - Some(hover) if !Self::is_attached(link, hover.node) => { - self.link_opacity * (1. - HOVER_DIM * hover.focus) - } - _ => self.link_opacity, - }; - window.paint_path( - path, - linear_gradient( - 90., - linear_color_stop(colors[link.source].opacity(opacity), 0.), - linear_color_stop(colors[link.target].opacity(opacity), 1.), - ), - ); - } + }); + let Some(path) = path else { + continue; + }; + let opacity = match self.hover { + Some(hover) if !Self::is_attached(link, hover.node) => { + self.link_opacity * (1. - HOVER_DIM * hover.focus) + } + _ => self.link_opacity, + }; + window.paint_path( + path, + linear_gradient( + 90., + linear_color_stop(colors[link.source].opacity(opacity), 0.), + linear_color_stop(colors[link.target].opacity(opacity), 1.), + ), + ); + } - let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default()); - for node in &graph.nodes { - let node_bounds = Bounds::from_corners( - origin_point(px(node.x0), px(node.y0), bounds.origin), - // Keep tiny nodes visible with a minimum 1px height. - origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin), - ); - window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii)); - } + let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default()); + for node in &graph.nodes { + let node_bounds = Bounds::from_corners( + origin_point(px(node.x0), px(node.y0), bounds.origin), + // Keep tiny nodes visible with a minimum 1px height. + origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin), + ); + window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii)); + } + }); let mut texts = Vec::new(); for node in &graph.nodes { @@ -695,7 +708,7 @@ impl Plot for SankeyChart { Text::new( text, point(px(x), px(y)), - line.color.unwrap_or(cx.theme().foreground), + line.color.unwrap_or(cx.theme().foreground).opacity(appear), ) .font_size(font_size) .align(align), @@ -707,7 +720,19 @@ impl Plot for SankeyChart { } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/theme/mod.rs b/crates/component/src/theme/mod.rs index 83a53cbc53..5fd0ebb301 100644 --- a/crates/component/src/theme/mod.rs +++ b/crates/component/src/theme/mod.rs @@ -61,6 +61,14 @@ fn default_true() -> bool { /// renderer, so this is simply "as round as it goes". const RADIUS_FULL: Pixels = px(9999.); +/// How long a chart's data takes to draw in the first time it is painted. +/// +/// ECharts draws a series in over 1000 ms on a cubic ease-out. A desktop +/// screen full of charts reads that as waiting, so this halves it on the +/// design system's enter curve: long enough to show the shape of the data +/// arriving, short enough to be done before the eye settles on it. +const PLOT_APPEAR: Duration = Duration::from_millis(500); + /// How long the scrollbar stays visible after the last scroll, drag, or hover. const SCROLLBAR_IDLE: Duration = Duration::from_secs(2); /// How long the scrollbar takes to appear. @@ -93,6 +101,7 @@ const SCROLLBAR_THUMB_INSET: Pixels = px(4.); /// the way there in the first third. The fast tier as a critically damped /// response lands in the same place, and the tolerance is sub-pixel so the /// spring rests once nothing visible moves. The hover fades on the same tier. +/// The data draws in over [`PLOT_APPEAR`]. fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::PlotMotion::default() .with_pointer(gpui_base::Spring::new(motion.duration_fast).with_epsilon(0.1)) @@ -104,6 +113,9 @@ fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::motion::Transition::new(motion.duration_fast) .easing(motion.easing_exit.clone()), ) + .with_appear( + gpui_base::motion::Transition::new(PLOT_APPEAR).easing(motion.easing_enter.clone()), + ) } /// The scrollbar motion this design system projects onto Base. diff --git a/website/base/plot.md b/website/base/plot.md index 9e48d1e830..9703f878d3 100644 --- a/website/base/plot.md +++ b/website/base/plot.md @@ -112,7 +112,7 @@ Shapes that repaint every frame can keep their tessellated paths across frames w ## Hover and tooltips -A plot opts into hover by returning an id from `Plot::id`. `PlotElement` then tracks the cursor each frame, occlusion-aware so an open popup above the plot clears it, and asks the plot three questions: +A plot opts into hover by returning an id from `Plot::id`; one that returns `false` from `Plot::interactive` keeps the id's state and its appear, but no hitbox or hover. `PlotElement` then tracks the cursor each frame, occlusion-aware so an open popup above the plot clears it, and asks the plot three questions: 1. `tooltip_state` — map the cursor to a [`TooltipState`]: the hovered index, the crosshair point and the data dots, or `None`. 2. `hover` — receive the hovered [`PlotHover`] before painting. It lingers after the cursor leaves while `progress()` eases back to zero, so emphasis fades out over the last datum instead of vanishing. `is_entering()` is true on the first hovered frame. @@ -138,8 +138,11 @@ use gpui_kit::base::{PlotMotion, PlotTheme, Spring, Theme, motion::Transition}; let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) - .with_exit(Transition::new(Duration::from_millis(120))); + .with_exit(Transition::new(Duration::from_millis(120))) + .with_appear(Transition::new(Duration::from_millis(500))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` +`with_appear` sets how a plot's data draws in the first time its id is painted. A plot receives the progress in `Plot::appear`, before `Plot::hover` and `Plot::paint`; `PlotAppear::staggered` gives each of several marks its own slice of it. A plot opts in by returning `Some` from `Plot::appear_generation`, and a new value replays the appear; the default `None` tracks nothing and asks for no frames. A plot with an id that is not `Plot::interactive` still appears, and hover tracking waits until the appear is done. + GPUI Component projects its motion tokens here whenever its theme changes. Motion honors the operating system's reduced-motion preference, under which every value adopts its target at once. diff --git a/website/component/chart.md b/website/component/chart.md index 167f3fd6f2..73c4535862 100644 --- a/website/component/chart.md +++ b/website/component/chart.md @@ -768,17 +768,37 @@ AreaChart::new(range).interactive(false) // A backdrop under drag handles The second matters because a plain hitbox does not block the one behind it: an element painted over an interactive chart is hovered *and so is the chart*, so the crosshair keeps tracking under it. The chart has to stand down. +A chart that is off keeps its id, so it still draws in and keeps its cache. + ### Motion The emphasis is animated with the styled layer's motion tokens (`cx.theme().motion_tokens()`), which the theme projects onto gpui-base as its [`PlotMotion`](../base/plot.md#motion): pointers — crosshair, band, dots — follow the hovered datum on a fast spring, a pie slice lifts on the control spring, and the whole overlay fades in when the cursor lands on a datum and out after it leaves. The motion honors the operating system's reduced-motion preference, under which every value adopts its target at once. +### Appear + +The data draws in the first time a chart is painted, over 500 ms on the enter curve: a line, an area, candlesticks and a sankey diagram are revealed from the left, bars grow out of the zero line one after another, a pie sweeps clockwise from its first slice, and a radar grows out of its center. Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. + +The appear runs once per id. New data paints in place, so a chart fed live quotes does not draw in again on every tick. To replay it when the chart starts showing something else, such as another symbol or period, hand it a key: + +```rust +LineChart::new(candles).appear_key((&symbol, period)) +``` + +A chart that is painted again each time it scrolls into view — one in each row of a long list — draws in every time it does. Turn the appear off there: + +```rust +LineChart::new(intraday).interactive(false).appear(false) +``` + +Reduced motion skips the appear. + ### Caching -A chart also keeps its heavy geometry across frames, since it repaints on every frame it is on screen: line and area strokes and pie slices stay tessellated while their projected points are unchanged, and a sankey diagram keeps its placement while its data, settings and size are unchanged. This cache hangs off the same id, so charts sharing one share the cache and thrash it — another reason to name siblings apart — and a chart with `interactive(false)`, having no id of its own, rebuilds its geometry on each paint. +A chart also keeps its heavy geometry across frames, since it repaints on every frame it is on screen: line and area strokes and pie slices stay tessellated while their projected points are unchanged, and a sankey diagram keeps its placement while its data, settings and size are unchanged. This cache hangs off the same id, so charts sharing one share the cache and thrash it — another reason to name siblings apart. A pie or radar being drawn in is a new shape on every frame, so it tessellates afresh until the appear ends. ### Custom Plots -A custom [`Plot`] opts in by hand — `Plot::id` defaults to `None` there. The trait, `PlotElement` and hover tracking come from [gpui-base](../base/plot.md), so a plot written against `gpui_kit::base::plot` works here unchanged. Return an id from `Plot::id`, resolve the datum under the cursor in `Plot::tooltip_state`, and build the overlay in `Plot::tooltip`. The `Tooltip` returned there animates the hover on its own, the same way the built-in charts do: the whole overlay fades with the hover, the crosshair and dots glide to each hovered datum on the pointer spring, adopting it on the frame the cursor lands, and a dot's `halo` grows as the hover fades in. A crosshair glides along the axis it marks only, so a line that also follows the cursor keeps up with it. Pass the data point itself; the tooltip does the rest: +A custom [`Plot`] opts in by hand — `Plot::id` defaults to `None` there. The trait, `PlotElement` and hover tracking come from [gpui-base](../base/plot.md), so a plot written against `gpui_kit::base::plot` works here unchanged. Return an id from `Plot::id`, resolve the datum under the cursor in `Plot::tooltip_state`, and build the overlay in `Plot::tooltip`. To draw in, keep the `PlotAppear` that `Plot::appear` hands over on every frame and paint with its progress. The `Tooltip` returned there animates the hover on its own, the same way the built-in charts do: the whole overlay fades with the hover, the crosshair and dots glide to each hovered datum on the pointer spring, adopting it on the frame the cursor lands, and a dot's `halo` grows as the hover fades in. A crosshair glides along the axis it marks only, so a line that also follows the cursor keeps up with it. Pass the data point itself; the tooltip does the rest: ```rust fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option { diff --git a/website/zh-CN/base/plot.md b/website/zh-CN/base/plot.md index 6cd8d4e1e7..9fdc8ca28a 100644 --- a/website/zh-CN/base/plot.md +++ b/website/zh-CN/base/plot.md @@ -112,7 +112,7 @@ Line::new() ## Hover 与 tooltip -Plot 在 `Plot::id` 中返回 id 即可启用 hover。之后 `PlotElement` 每帧跟踪光标(会识别遮挡,Plot 上方打开的弹出层会清除 hover),并依次询问 Plot: +Plot 在 `Plot::id` 中返回 id 即可启用 hover;若 `Plot::interactive` 返回 `false`,则保留 id 对应的状态和入场,但没有 hitbox 和 hover。之后 `PlotElement` 每帧跟踪光标(会识别遮挡,Plot 上方打开的弹出层会清除 hover),并依次询问 Plot: 1. `tooltip_state`:把光标映射成 [`TooltipState`],包括悬停的索引、十字线位置和数据点,或返回 `None`。 2. `hover`:在绘制前接收当前的 [`PlotHover`]。光标离开后它会继续保留,同时 `progress()` 逐渐回落到零,让强调效果在最后一个数据项上淡出,而不是突然消失。首个悬停帧上 `is_entering()` 为 true。 @@ -138,8 +138,11 @@ use gpui_kit::base::{PlotMotion, PlotTheme, Spring, Theme, motion::Transition}; let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) - .with_exit(Transition::new(Duration::from_millis(120))); + .with_exit(Transition::new(Duration::from_millis(120))) + .with_appear(Transition::new(Duration::from_millis(500))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` +`with_appear` 设置 plot 的 id 第一次绘制时数据画出来的方式。plot 在 `Plot::appear` 里拿到进度,调用时机在 `Plot::hover` 和 `Plot::paint` 之前;`PlotAppear::staggered` 把进度切给多个图形,每个错开一点开始。plot 在 `Plot::appear_generation` 返回 `Some` 即开启入场,值变化时重播;默认的 `None` 不跟踪任何状态,也不请求帧。有 id 但 `Plot::interactive` 为 false 的 plot 同样会入场;入场结束前不跟踪 hover。 + GPUI Component 会在主题变化时把自己的 motion token 投射到这里。动效遵循操作系统的“减少动态效果”设置,开启后所有值都会立即采用目标值。 diff --git a/website/zh-CN/component/chart.md b/website/zh-CN/component/chart.md index 73209f2dfb..9cfb80952f 100644 --- a/website/zh-CN/component/chart.md +++ b/website/zh-CN/component/chart.md @@ -742,17 +742,37 @@ AreaChart::new(range).interactive(false) // 拖拽手柄下面的底图 第二种尤其要注意:普通 hitbox **不会挡住它后面的 hitbox**,盖在图表上的元素被悬停时,图表**同样**算被悬停,十字线会在它下面继续跟着跑。只能让图表让位。 +关闭交互的图表仍保留自己的 id,所以依然有入场动画,也依然保留缓存。 + ### 动效 强调效果使用样式层的 motion tokens(`cx.theme().motion_tokens()`)驱动,主题会把它们作为 [`PlotMotion`](../base/plot.md) 投射到 gpui-base:十字线、高亮条、圆点等指示器以快速弹簧跟随悬停的数据,饼图扇区以 control 弹簧抬起,整个覆盖层在光标落到数据上时淡入、离开后淡出。动效遵循操作系统的减弱动态效果偏好,开启后所有值立即到达目标。 +### 入场 + +图表第一次绘制时,数据会按 enter 曲线在 500 ms 内画出来:折线、面积、K 线和桑基图从左往右展开,柱子从零线依次长出,饼图从第一个扇区顺时针扫开,雷达图从中心向外放大。坐标轴、网格线和刻度文字从第一帧起就完整显示,tooltip 等数据画完才出现。 + +每个 id 只播放一次入场。之后数据变化会原地重绘,所以接实时行情的图表不会每次推送都重播。图表换成展示别的内容(比如换了股票或周期)时,传一个 key 让它重播: + +```rust +LineChart::new(candles).appear_key((&symbol, period)) +``` + +每次滚入视野都会重新绘制的图表(比如长列表每一行里的图表),每次都会重播入场。这种场景把入场关掉: + +```rust +LineChart::new(intraday).interactive(false).appear(false) +``` + +开启减弱动态效果时跳过入场。 + ### 缓存 -图表还会跨帧保留较重的几何计算,因为它在屏幕上的每一帧都会重绘:折线与面积的描边、饼图扇区在投影点不变时保持已细分的路径,桑基图在数据、设置和尺寸不变时保留布局。这份缓存挂在同一个 id 上,因此共用 id 的图表会互相冲刷缓存——这是同级图表需要分别命名的另一个理由;而 `interactive(false)` 的图表没有自己的 id,每次绘制都会重算几何。 +图表还会跨帧保留较重的几何计算,因为它在屏幕上的每一帧都会重绘:折线与面积的描边、饼图扇区在投影点不变时保持已细分的路径,桑基图在数据、设置和尺寸不变时保留布局。这份缓存挂在同一个 id 上,因此共用 id 的图表会互相冲刷缓存——这是同级图表需要分别命名的另一个理由。饼图和雷达图入场时每一帧都是新的形状,所以入场结束前每帧都重新细分路径。 ### 自定义 Plot -自定义 [`Plot`] 需要手动接入——那里的 `Plot::id` 仍默认返回 `None`。这个 trait、`PlotElement` 和 hover 跟踪都来自 [gpui-base](../base/plot.md),因此基于 `gpui_kit::base::plot` 编写的 Plot 可以直接在这里使用。在 `Plot::id` 返回 id,在 `Plot::tooltip_state` 解析光标所在的数据,在 `Plot::tooltip` 构建覆盖层。这里返回的 `Tooltip` 会自己为悬停加动画,和内置图表一样:整个覆盖层随悬停淡入淡出;十字线和圆点按指针 spring 滑到每个悬停的数据点,光标落下的那一帧直接就位;圆点的 `halo` 随悬停淡入逐渐放大。十字线只沿它标记的那条轴滑动,所以同时跟随光标的那条线不会滞后。传入数据点本身即可,其余交给 tooltip: +自定义 [`Plot`] 需要手动接入——那里的 `Plot::id` 仍默认返回 `None`。这个 trait、`PlotElement` 和 hover 跟踪都来自 [gpui-base](../base/plot.md),因此基于 `gpui_kit::base::plot` 编写的 Plot 可以直接在这里使用。在 `Plot::id` 返回 id,在 `Plot::tooltip_state` 解析光标所在的数据,在 `Plot::tooltip` 构建覆盖层。要做入场,就保存 `Plot::appear` 每帧传入的 `PlotAppear`,用它的进度来绘制。这里返回的 `Tooltip` 会自己为悬停加动画,和内置图表一样:整个覆盖层随悬停淡入淡出;十字线和圆点按指针 spring 滑到每个悬停的数据点,光标落下的那一帧直接就位;圆点的 `halo` 随悬停淡入逐渐放大。十字线只沿它标记的那条轴滑动,所以同时跟随光标的那条线不会滞后。传入数据点本身即可,其余交给 tooltip: ```rust fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option { From d7f17a5ccd7748df0c01a4581f3f6bc923c112e2 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 28 Sep 2026 21:19:03 +0800 Subject: [PATCH 2/6] story: Add a replay button to the chart gallery 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 --- .../src/stories/chart_story/chart_story.rs | 87 +++++++++++++------ release-notes.md | 28 ++++++ 2 files changed, 87 insertions(+), 28 deletions(-) diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index ab14f3a78d..1f1da29369 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -4,6 +4,7 @@ use gpui_kit::assets::IconName; use gpui_kit::base::ElementExt as _; use gpui_kit::component::{ ActiveTheme, Icon, StyledExt, + button::Button, chart::{ AreaChart, BarChart, CandlestickChart, LineChart, PieChart, RadarChart, SankeyChart, SankeyLabel, @@ -16,10 +17,10 @@ use gpui_kit::component::{ v_flex, }; use gpui_kit::{ - AnyElement, App, AppContext, Background, Context, Corners, Entity, FocusHandle, Focusable, - FontWeight, Hsla, IntoElement, ListAlignment, ListState, ParentElement, Pixels, Render, Rgba, - SharedString, Styled, Window, div, linear_color_stop, linear_gradient, list, - prelude::FluentBuilder, px, + AnyElement, App, AppContext, Background, Context, Corners, ElementId, Entity, FocusHandle, + Focusable, FontWeight, Hsla, InteractiveElement as _, IntoElement, ListAlignment, ListState, + ParentElement, Pixels, Render, Rgba, SharedString, Styled, Window, div, linear_color_stop, + linear_gradient, list, prelude::FluentBuilder, px, }; use serde::Deserialize; @@ -1475,6 +1476,9 @@ pub struct ChartStory { /// the last prepaint. columns: usize, list_state: ListState, + /// Bumped by the replay button. The gallery is keyed on it, so every chart + /// gets fresh element state and draws in again. + appear_generation: u64, } fn fixture Deserialize<'de>>(json: &str) -> T { @@ -1571,6 +1575,7 @@ impl ChartStory { sections, columns, list_state, + appear_generation: 0, } } @@ -1669,39 +1674,65 @@ impl Render for ChartStory { let data = self.data.clone(); let story = cx.entity(); - div() + v_flex() .size_full() .bg(cx.theme().background) .on_prepaint(move |bounds, _, cx| { story.update(cx, |this, cx| this.measure(bounds.size.width, cx)); }) .child( - list(self.list_state.clone(), move |index, _, cx| { - let Some(row) = rows.get(index) else { - return div().into_any_element(); - }; - - div() - .w_full() - .px(CONTENT_INSET) - // Spacing between rows only, like a CSS gap. - .when(index + 1 < rows.len(), |this| this.pb(CARD_GAP)) - .child(match row { - ChartRow::Rule => Separator::horizontal().into_any_element(), - ChartRow::Cards(cards) => h_flex() + h_flex() + .w_full() + .justify_end() + .px(CONTENT_INSET) + .pt(CONTENT_INSET) + .child( + Button::new("chart-replay") + .icon(IconName::RotateCw) + .label("Replay") + .on_click(cx.listener(|this, _, _, cx| { + this.appear_generation += 1; + cx.notify(); + })), + ), + ) + .child( + div() + .id(ElementId::NamedInteger( + "chart-gallery".into(), + self.appear_generation, + )) + .flex_1() + .min_h_0() + .w_full() + .child( + list(self.list_state.clone(), move |index, _, cx| { + let Some(row) = rows.get(index) else { + return div().into_any_element(); + }; + + div() .w_full() - .gap(CARD_GAP) - .children(cards.iter().map(|card| card.render(&data, cx))) - .into_any_element(), + .px(CONTENT_INSET) + // Spacing between rows only, like a CSS gap. + .when(index + 1 < rows.len(), |this| this.pb(CARD_GAP)) + .child(match row { + ChartRow::Rule => Separator::horizontal().into_any_element(), + ChartRow::Cards(cards) => h_flex() + .w_full() + .gap(CARD_GAP) + .children(cards.iter().map(|card| card.render(&data, cx))) + .into_any_element(), + }) + .into_any_element() }) - .into_any_element() - }) - .size_full() - // The list's own style honours vertical padding only, so the - // horizontal inset rides on each row above. - .py(CONTENT_INSET), + .size_full() + // The list's own style honours vertical padding only, so the + // horizontal inset rides on each row above. + .py(CONTENT_INSET), + ) + .vertical_scrollbar(&self.list_state), ) - .vertical_scrollbar(&self.list_state) } } diff --git a/release-notes.md b/release-notes.md index 9d01fc2044..a78d9dc0f1 100644 --- a/release-notes.md +++ b/release-notes.md @@ -47,6 +47,30 @@ motionless by default, and `gpui-component` projects its motion tokens onto `gpui_base::Theme::plot` whenever its theme is applied. The `decimal` feature moves to `gpui-base`; `gpui-component`'s `decimal` feature forwards to it. +#### Added: chart appear motion + +Charts draw their data in the first time they are painted, over 500 ms on the +theme's enter curve: 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 out of its center. Axes, grids and labels are there from the +first frame, the tooltip waits until the data is whole, and reduced motion +skips it. New data paints in place, so a chart fed live quotes does not replay. + +```rust +pub fn appear(self, appear: bool) -> Self // every chart: opt out, e.g. in list rows +pub fn appear_key(self, key: impl Hash) -> Self // every chart: replay when the key changes +``` + +Custom plots opt in through `gpui_base::plot`: + +```rust +pub struct PlotAppear // progress(), staggered(index, count, spread), is_appearing(), complete() +fn Plot::appear(&mut self, appear: PlotAppear, window: &mut Window, cx: &mut App) +fn Plot::appear_generation(&self) -> Option // Some opts in; a new value replays +fn Plot::interactive(&self) -> bool // hover and tooltip, apart from the id +pub fn PlotMotion::with_appear(self, appear: Transition) -> Self +``` + #### Breaking changes ##### Plot API @@ -78,6 +102,10 @@ the new `f32` support. Custom plots built on the primitives need these changes: `#[non_exhaustive]`; build them with their constructors. - `#[derive(IntoPlot)]` generates `type Element = PlotElement` instead of an `Element` impl on the plot; `gpui_base::Theme` gains a `plot` field. +- `Plot::id` gives a plot element state; whether it tracks hover is + `Plot::interactive` (`true` by default, so an existing plot is unchanged). + A chart with `interactive(false)` now returns its id, keeping its appear and + path caches but still no hitbox. `StrokeStyle` and `stroke_style` are removed outright. Deprecated aliases keep `AXIS_GAP`, `dot_fill_color`, `dot_stroke_color` and `focus` compiling for this From 4fe527e5b10b06a79f66f91f136a9dedda6296aa Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 28 Sep 2026 21:29:38 +0800 Subject: [PATCH 3/6] chart: Draw data in over 1000 ms on `cubicInOut` 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 --- crates/component/src/theme/mod.rs | 27 ++++++++++++++++++--------- release-notes.md | 4 ++-- website/base/plot.md | 2 +- website/component/chart.md | 2 +- website/zh-CN/base/plot.md | 2 +- website/zh-CN/component/chart.md | 2 +- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/component/src/theme/mod.rs b/crates/component/src/theme/mod.rs index 5fd0ebb301..b2145b201e 100644 --- a/crates/component/src/theme/mod.rs +++ b/crates/component/src/theme/mod.rs @@ -63,11 +63,18 @@ const RADIUS_FULL: Pixels = px(9999.); /// How long a chart's data takes to draw in the first time it is painted. /// -/// ECharts draws a series in over 1000 ms on a cubic ease-out. A desktop -/// screen full of charts reads that as waiting, so this halves it on the -/// design system's enter curve: long enough to show the shape of the data -/// arriving, short enough to be done before the eye settles on it. -const PLOT_APPEAR: Duration = Duration::from_millis(500); +/// The mainstream chart libraries agree on about a second: ECharts, Chart.js +/// and Highcharts all default to 1000 ms, ApexCharts to 800 ms and Recharts to +/// 1500 ms. +const PLOT_APPEAR: Duration = Duration::from_millis(1000); + +/// The curve a chart's data draws in on: ECharts' `cubicInOut`. +/// +/// Not the design system's enter curve. That one is an exponential ease-out +/// built for a popover, done nine-tenths of the way in the first quarter, which +/// makes a second of drawing in read as a flash. Data growing into place starts +/// and settles gently instead, as ECharts, Highcharts and ApexCharts draw it. +const PLOT_APPEAR_EASING: (f32, f32, f32, f32) = (0.645, 0.045, 0.355, 1.0); /// How long the scrollbar stays visible after the last scroll, drag, or hover. const SCROLLBAR_IDLE: Duration = Duration::from_secs(2); @@ -101,7 +108,7 @@ const SCROLLBAR_THUMB_INSET: Pixels = px(4.); /// the way there in the first third. The fast tier as a critically damped /// response lands in the same place, and the tolerance is sub-pixel so the /// spring rests once nothing visible moves. The hover fades on the same tier. -/// The data draws in over [`PLOT_APPEAR`]. +/// The data draws in over [`PLOT_APPEAR`] on [`PLOT_APPEAR_EASING`]. fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::PlotMotion::default() .with_pointer(gpui_base::Spring::new(motion.duration_fast).with_epsilon(0.1)) @@ -113,9 +120,11 @@ fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::motion::Transition::new(motion.duration_fast) .easing(motion.easing_exit.clone()), ) - .with_appear( - gpui_base::motion::Transition::new(PLOT_APPEAR).easing(motion.easing_enter.clone()), - ) + .with_appear(gpui_base::motion::Transition::new(PLOT_APPEAR).easing({ + let (x1, y1, x2, y2) = PLOT_APPEAR_EASING; + gpui_base::motion::Easing::cubic_bezier(x1, y1, x2, y2) + .expect("static appear curve is valid") + })) } /// The scrollbar motion this design system projects onto Base. diff --git a/release-notes.md b/release-notes.md index a78d9dc0f1..34726d5a2b 100644 --- a/release-notes.md +++ b/release-notes.md @@ -49,8 +49,8 @@ moves to `gpui-base`; `gpui-component`'s `decimal` feature forwards to it. #### Added: chart appear motion -Charts draw their data in the first time they are painted, over 500 ms on the -theme's enter curve: line, area, candlestick and sankey charts reveal from the +Charts draw their data in the first time they are painted, over 1000 ms on +ECharts' `cubicInOut` curve: 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 out of its center. Axes, grids and labels are there from the first frame, the tooltip waits until the data is whole, and reduced motion diff --git a/website/base/plot.md b/website/base/plot.md index 9703f878d3..ffe2968d61 100644 --- a/website/base/plot.md +++ b/website/base/plot.md @@ -139,7 +139,7 @@ let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) .with_exit(Transition::new(Duration::from_millis(120))) - .with_appear(Transition::new(Duration::from_millis(500))); + .with_appear(Transition::new(Duration::from_millis(1000))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` diff --git a/website/component/chart.md b/website/component/chart.md index 73c4535862..0ec28ef8d0 100644 --- a/website/component/chart.md +++ b/website/component/chart.md @@ -776,7 +776,7 @@ The emphasis is animated with the styled layer's motion tokens (`cx.theme().moti ### Appear -The data draws in the first time a chart is painted, over 500 ms on the enter curve: a line, an area, candlesticks and a sankey diagram are revealed from the left, bars grow out of the zero line one after another, a pie sweeps clockwise from its first slice, and a radar grows out of its center. Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. +The data draws in the first time a chart is painted, over 1000 ms on ECharts' `cubicInOut` curve, the second that ECharts, Chart.js and Highcharts all default to: a line, an area, candlesticks and a sankey diagram are revealed from the left, bars grow out of the zero line one after another, a pie sweeps clockwise from its first slice, and a radar grows out of its center. Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. The appear runs once per id. New data paints in place, so a chart fed live quotes does not draw in again on every tick. To replay it when the chart starts showing something else, such as another symbol or period, hand it a key: diff --git a/website/zh-CN/base/plot.md b/website/zh-CN/base/plot.md index 9fdc8ca28a..b59237efb0 100644 --- a/website/zh-CN/base/plot.md +++ b/website/zh-CN/base/plot.md @@ -139,7 +139,7 @@ let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) .with_exit(Transition::new(Duration::from_millis(120))) - .with_appear(Transition::new(Duration::from_millis(500))); + .with_appear(Transition::new(Duration::from_millis(1000))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` diff --git a/website/zh-CN/component/chart.md b/website/zh-CN/component/chart.md index 9cfb80952f..436b9da119 100644 --- a/website/zh-CN/component/chart.md +++ b/website/zh-CN/component/chart.md @@ -750,7 +750,7 @@ AreaChart::new(range).interactive(false) // 拖拽手柄下面的底图 ### 入场 -图表第一次绘制时,数据会按 enter 曲线在 500 ms 内画出来:折线、面积、K 线和桑基图从左往右展开,柱子从零线依次长出,饼图从第一个扇区顺时针扫开,雷达图从中心向外放大。坐标轴、网格线和刻度文字从第一帧起就完整显示,tooltip 等数据画完才出现。 +图表第一次绘制时,数据会按 ECharts 的 `cubicInOut` 曲线在 1000 ms 内画出来(ECharts、Chart.js、Highcharts 的默认时长都是 1000 ms):折线、面积、K 线和桑基图从左往右展开,柱子从零线依次长出,饼图从第一个扇区顺时针扫开,雷达图从中心向外放大。坐标轴、网格线和刻度文字从第一帧起就完整显示,tooltip 等数据画完才出现。 每个 id 只播放一次入场。之后数据变化会原地重绘,所以接实时行情的图表不会每次推送都重播。图表换成展示别的内容(比如换了股票或周期)时,传一个 key 让它重播: From f87c314c6ed78f91dace96d9da2094817d2701cd Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 28 Sep 2026 21:32:14 +0800 Subject: [PATCH 4/6] story: Match the chart replay button to the shared story toolbar 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 --- .../src/stories/chart_story/chart_story.rs | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index 1f1da29369..b29b80217b 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -25,7 +25,7 @@ use gpui_kit::{ use serde::Deserialize; use super::StackedBarChart; -use crate::Story; +use crate::{Story, story_toolbar_group}; /// The height of one chart card, and the list's overdraw: the virtual list /// keeps one row of cards live on either side of the viewport. @@ -1680,20 +1680,23 @@ impl Render for ChartStory { .on_prepaint(move |bounds, _, cx| { story.update(cx, |this, cx| this.measure(bounds.size.width, cx)); }) + // The toolbar stays put while the gallery scrolls under it, so the + // gap below it belongs to the toolbar, not to the list's padding. .child( - h_flex() - .w_full() - .justify_end() + div() .px(CONTENT_INSET) .pt(CONTENT_INSET) + .pb(CARD_GAP) .child( - Button::new("chart-replay") - .icon(IconName::RotateCw) - .label("Replay") - .on_click(cx.listener(|this, _, _, cx| { - this.appear_generation += 1; - cx.notify(); - })), + story_toolbar_group().child( + Button::new("chart-replay") + .icon(IconName::RotateCw) + .label("Replay") + .on_click(cx.listener(|this, _, _, cx| { + this.appear_generation += 1; + cx.notify(); + })), + ), ), ) .child( @@ -1728,8 +1731,9 @@ impl Render for ChartStory { }) .size_full() // The list's own style honours vertical padding only, so the - // horizontal inset rides on each row above. - .py(CONTENT_INSET), + // horizontal inset rides on each row above; the toolbar + // above holds the top gap. + .pb(CONTENT_INSET), ) .vertical_scrollbar(&self.list_state), ) From be96fd225c383a862e3dae994dd37d8a62749440 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 28 Sep 2026 21:37:29 +0800 Subject: [PATCH 5/6] chart: Grow bars together on Chart.js' `easeOutQuart` 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 --- crates/component/src/chart/bar_chart.rs | 29 ++++++++++--------------- crates/component/src/theme/mod.rs | 18 +++++++-------- release-notes.md | 4 ++-- website/component/chart.md | 2 +- website/zh-CN/component/chart.md | 2 +- 5 files changed, 23 insertions(+), 32 deletions(-) diff --git a/crates/component/src/chart/bar_chart.rs b/crates/component/src/chart/bar_chart.rs index cf4ef698f8..b18055362f 100644 --- a/crates/component/src/chart/bar_chart.rs +++ b/crates/component/src/chart/bar_chart.rs @@ -984,19 +984,17 @@ where 1. - HOVER_DIM * hover.focus * distance }; - // Each bar grows out of the zero line as the chart appears, one after - // another across the first half of the appear. - let appear = self.appear.get().clone(); - let count = self.data.len(); - let label_appear = appear.clone(); + // Every bar grows out of the zero line together as the chart appears, + // the way Chart.js draws bars in. + let appear = self.appear.get().progress(); let mut bar = Bar::new() - .data(self.data.iter().enumerate()) + .data(&self.data) .alignment(alignment) .band_width(band_width) - .cross(move |(_, d)| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset)) + .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset)) .base(move |_| zero_pixel) - .value(move |(ix, d)| { + .value(move |d| { let end = bar_end( &value_scale, value_fn_cloned(d), @@ -1004,22 +1002,21 @@ where alignment, min_length, )?; - let grown = appear.staggered(*ix, count, APPEAR_SPREAD); - Some(zero_pixel + (end - zero_pixel) * grown) + Some(zero_pixel + (end - zero_pixel) * appear) }) .corner_radii(self.corner_radii); bar = match (fill, fill_gradient) { (_, Some(fg)) => { let value_fn_for_grad = value_fn.clone(); - bar.fill(move |(_, d), frame, alignment| { + bar.fill(move |d, frame, alignment| { let v = value_fn_for_grad(d).to_f32().unwrap_or(0.); let [s0, s1] = bar_gradient(fg.as_ref(), d, v, chart_range.clone()); let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1); bg.opacity(emphasis(frame)) }) } - (Some(f), _) => bar.fill(move |(_, d), frame, alignment| { + (Some(f), _) => bar.fill(move |d, frame, alignment| { f(d, frame, chart_bounds, alignment).opacity(emphasis(frame)) }), _ => bar.fill(move |_, frame, _| default_fill.opacity(emphasis(frame))), @@ -1032,12 +1029,12 @@ where BarAlignment::Left => TextAlign::Left, BarAlignment::Right => TextAlign::Right, }; - bar = bar.label(move |(ix, d), p| { + bar = bar.label(move |d, p| { // A value label rides the end of its bar and fades in with it. let color = label_color_fn .as_ref() .map_or(label_color, |f| f(d)) - .opacity(label_appear.staggered(*ix, count, APPEAR_SPREAD)); + .opacity(appear); vec![Text::new(label(d), p, color).align(text_align)] }); } @@ -1180,10 +1177,6 @@ where } } -/// How much of the appear the bars' starts are spread across; each bar grows -/// over the rest. -const APPEAR_SPREAD: f32 = 0.5; - /// The end a bar showing `value` reaches along the value axis, at least /// `min_length` pixels from `zero`. fn bar_end( diff --git a/crates/component/src/theme/mod.rs b/crates/component/src/theme/mod.rs index b2145b201e..facc3f5a25 100644 --- a/crates/component/src/theme/mod.rs +++ b/crates/component/src/theme/mod.rs @@ -68,13 +68,15 @@ const RADIUS_FULL: Pixels = px(9999.); /// 1500 ms. const PLOT_APPEAR: Duration = Duration::from_millis(1000); -/// The curve a chart's data draws in on: ECharts' `cubicInOut`. +/// The curve a chart's data draws in on: Chart.js' default `easeOutQuart`. /// /// Not the design system's enter curve. That one is an exponential ease-out /// built for a popover, done nine-tenths of the way in the first quarter, which -/// makes a second of drawing in read as a flash. Data growing into place starts -/// and settles gently instead, as ECharts, Highcharts and ApexCharts draw it. -const PLOT_APPEAR_EASING: (f32, f32, f32, f32) = (0.645, 0.045, 0.355, 1.0); +/// makes a second of drawing in read as a flash. The quartic still leads with +/// most of the motion but leaves the data a visible glide into place. +fn plot_appear_easing(t: f32) -> f32 { + 1. - (1. - t).powi(4) +} /// How long the scrollbar stays visible after the last scroll, drag, or hover. const SCROLLBAR_IDLE: Duration = Duration::from_secs(2); @@ -108,7 +110,7 @@ const SCROLLBAR_THUMB_INSET: Pixels = px(4.); /// the way there in the first third. The fast tier as a critically damped /// response lands in the same place, and the tolerance is sub-pixel so the /// spring rests once nothing visible moves. The hover fades on the same tier. -/// The data draws in over [`PLOT_APPEAR`] on [`PLOT_APPEAR_EASING`]. +/// The data draws in over [`PLOT_APPEAR`] on [`plot_appear_easing`]. fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::PlotMotion::default() .with_pointer(gpui_base::Spring::new(motion.duration_fast).with_epsilon(0.1)) @@ -120,11 +122,7 @@ fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::motion::Transition::new(motion.duration_fast) .easing(motion.easing_exit.clone()), ) - .with_appear(gpui_base::motion::Transition::new(PLOT_APPEAR).easing({ - let (x1, y1, x2, y2) = PLOT_APPEAR_EASING; - gpui_base::motion::Easing::cubic_bezier(x1, y1, x2, y2) - .expect("static appear curve is valid") - })) + .with_appear(gpui_base::motion::Transition::new(PLOT_APPEAR).ease(plot_appear_easing)) } /// The scrollbar motion this design system projects onto Base. diff --git a/release-notes.md b/release-notes.md index 34726d5a2b..db1bf716c0 100644 --- a/release-notes.md +++ b/release-notes.md @@ -50,8 +50,8 @@ moves to `gpui-base`; `gpui-component`'s `decimal` feature forwards to it. #### Added: chart appear motion Charts draw their data in the first time they are painted, over 1000 ms on -ECharts' `cubicInOut` curve: line, area, candlestick and sankey charts reveal from the -left, bars grow out of the zero line one after another, a pie sweeps clockwise +`easeOutQuart`: line, area, candlestick and sankey charts reveal from the left, +bars grow out of the zero line together, a pie sweeps clockwise and a radar grows out of its center. Axes, grids and labels are there from the first frame, the tooltip waits until the data is whole, and reduced motion skips it. New data paints in place, so a chart fed live quotes does not replay. diff --git a/website/component/chart.md b/website/component/chart.md index 0ec28ef8d0..d20c90438b 100644 --- a/website/component/chart.md +++ b/website/component/chart.md @@ -776,7 +776,7 @@ The emphasis is animated with the styled layer's motion tokens (`cx.theme().moti ### Appear -The data draws in the first time a chart is painted, over 1000 ms on ECharts' `cubicInOut` curve, the second that ECharts, Chart.js and Highcharts all default to: a line, an area, candlesticks and a sankey diagram are revealed from the left, bars grow out of the zero line one after another, a pie sweeps clockwise from its first slice, and a radar grows out of its center. Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. +The data draws in the first time a chart is painted, over 1000 ms on `easeOutQuart`, Chart.js' default curve: lines, areas, candlesticks and a sankey diagram are revealed from the left the way ECharts, Highcharts and Recharts draw them, bars grow out of the zero line together, a pie sweeps clockwise from its first slice, and a radar grows out of its center. Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. The appear runs once per id. New data paints in place, so a chart fed live quotes does not draw in again on every tick. To replay it when the chart starts showing something else, such as another symbol or period, hand it a key: diff --git a/website/zh-CN/component/chart.md b/website/zh-CN/component/chart.md index 436b9da119..41fd8420e1 100644 --- a/website/zh-CN/component/chart.md +++ b/website/zh-CN/component/chart.md @@ -750,7 +750,7 @@ AreaChart::new(range).interactive(false) // 拖拽手柄下面的底图 ### 入场 -图表第一次绘制时,数据会按 ECharts 的 `cubicInOut` 曲线在 1000 ms 内画出来(ECharts、Chart.js、Highcharts 的默认时长都是 1000 ms):折线、面积、K 线和桑基图从左往右展开,柱子从零线依次长出,饼图从第一个扇区顺时针扫开,雷达图从中心向外放大。坐标轴、网格线和刻度文字从第一帧起就完整显示,tooltip 等数据画完才出现。 +图表第一次绘制时,数据会按 `easeOutQuart` 曲线(Chart.js 的默认曲线)在 1000 ms 内画出来:折线、面积、K 线和桑基图像 ECharts、Highcharts、Recharts 那样从左往右展开,柱子从零线同时长出,饼图从第一个扇区顺时针扫开,雷达图从中心向外放大。坐标轴、网格线和刻度文字从第一帧起就完整显示,tooltip 等数据画完才出现。 每个 id 只播放一次入场。之后数据变化会原地重绘,所以接实时行情的图表不会每次推送都重播。图表换成展示别的内容(比如换了股票或周期)时,传一个 key 让它重播: From bcd7cabc13bc0c2aa9f3a4def6008566f4f06577 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 28 Sep 2026 22:02:32 +0800 Subject: [PATCH 6/6] release-notes: Move the `Plot::interactive` split out of breaking changes 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 --- release-notes.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/release-notes.md b/release-notes.md index db1bf716c0..7731c83183 100644 --- a/release-notes.md +++ b/release-notes.md @@ -71,6 +71,12 @@ fn Plot::interactive(&self) -> bool // hover and tooltip, apart fro pub fn PlotMotion::with_appear(self, appear: Transition) -> Self ``` +Every new `Plot` method has a default, so existing plots compile and behave as +before: `Plot::interactive` is `true`, and without an `appear_generation` a plot +tracks no appear and asks for no frames. A chart with `interactive(false)` now +returns its id from `Plot::id`, keeping its appear and path caches but still no +hitbox. + #### Breaking changes ##### Plot API @@ -102,10 +108,6 @@ the new `f32` support. Custom plots built on the primitives need these changes: `#[non_exhaustive]`; build them with their constructors. - `#[derive(IntoPlot)]` generates `type Element = PlotElement` instead of an `Element` impl on the plot; `gpui_base::Theme` gains a `plot` field. -- `Plot::id` gives a plot element state; whether it tracks hover is - `Plot::interactive` (`true` by default, so an existing plot is unchanged). - A chart with `interactive(false)` now returns its id, keeping its appear and - path caches but still no hitbox. `StrokeStyle` and `stroke_style` are removed outright. Deprecated aliases keep `AXIS_GAP`, `dot_fill_color`, `dot_stroke_color` and `focus` compiling for this