plot: Add support for stacked chart (#1681)

## Description

- Add `stack` shape.
- Add `ordinal` scale.
- Add plot docs.

<img width="337" height="424" alt="SCR-20251126-puzm"
src="https://github.com/user-attachments/assets/eff9e2d0-c13a-449a-b717-9568e0c2f88d"
/>

## Breaking change
The bar shape `y0` value is now returned for each data item.

```diff
- Bar::new().y0(height)
+ Bar::new().y0(move |_| height)
+ Bar::new().y0(move |d| d.height)
```
This commit is contained in:
Floyd Wang 2025-11-26 18:24:57 +08:00 committed by GitHub
parent 55da14edb2
commit 0d167f61e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1380 additions and 511 deletions

View file

@ -1,316 +1,5 @@
use gpui::{
App, AppContext, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, ParentElement,
Render, SharedString, Styled, Window, div, linear_color_stop, linear_gradient,
prelude::FluentBuilder, px,
};
use gpui_component::{
ActiveTheme, StyledExt,
chart::{AreaChart, BarChart, LineChart, PieChart},
divider::Divider,
dock::PanelControl,
h_flex, v_flex,
};
use serde::Deserialize;
mod chart_story;
mod stacked_bar_chart;
#[derive(Clone, Deserialize)]
struct MonthlyDevice {
pub month: SharedString,
pub desktop: f64,
pub color_alpha: f32,
}
impl MonthlyDevice {
pub fn color(&self, color: Hsla) -> Hsla {
color.alpha(self.color_alpha)
}
}
#[derive(Clone, Deserialize)]
struct DailyDevice {
pub date: SharedString,
pub desktop: f64,
pub mobile: f64,
}
pub struct ChartStory {
focus_handle: FocusHandle,
daily_devices: Vec<DailyDevice>,
monthly_devices: Vec<MonthlyDevice>,
}
impl ChartStory {
fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
let daily_devices =
serde_json::from_str::<Vec<DailyDevice>>(include_str!("fixtures/daily-devices.json"))
.unwrap();
let monthly_devices = serde_json::from_str::<Vec<MonthlyDevice>>(include_str!(
"fixtures/monthly-devices.json"
))
.unwrap();
Self {
daily_devices,
monthly_devices,
focus_handle: cx.focus_handle(),
}
}
pub fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(window, cx))
}
}
impl super::Story for ChartStory {
fn title() -> &'static str {
"Chart"
}
fn description() -> &'static str {
"Beautiful Charts & Graphs."
}
fn new_view(window: &mut Window, cx: &mut App) -> Entity<impl Render> {
Self::view(window, cx)
}
fn zoomable() -> Option<PanelControl> {
None
}
}
impl Focusable for ChartStory {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
fn chart_container(
title: &str,
chart: impl IntoElement,
center: bool,
cx: &mut Context<ChartStory>,
) -> impl IntoElement {
v_flex()
.flex_1()
.h_full()
.border_1()
.border_color(cx.theme().border)
.rounded_lg()
.p_4()
.child(
div()
.when(center, |this| this.text_center())
.font_semibold()
.child(title.to_string()),
)
.child(
div()
.when(center, |this| this.text_center())
.text_color(cx.theme().muted_foreground)
.text_sm()
.child("January-June 2025"),
)
.child(div().flex_1().py_4().child(chart))
.child(
div()
.when(center, |this| this.text_center())
.font_semibold()
.text_sm()
.child("Trending up by 5.2% this month"),
)
.child(
div()
.when(center, |this| this.text_center())
.text_color(cx.theme().muted_foreground)
.text_sm()
.child("Showing total visitors for the last 6 months"),
)
}
impl Render for ChartStory {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let color = cx.theme().chart_3;
v_flex()
.size_full()
.gap_y_4()
.bg(cx.theme().background)
.child(
div().h(px(400.)).child(chart_container(
"Area Chart - Stacked",
AreaChart::new(self.daily_devices.clone())
.x(|d| d.date.clone())
.y(|d| d.desktop)
.stroke(cx.theme().chart_1)
.fill(linear_gradient(
0.,
linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.),
linear_color_stop(cx.theme().background.opacity(0.3), 0.),
))
.y(|d| d.mobile)
.stroke(cx.theme().chart_2)
.fill(linear_gradient(
0.,
linear_color_stop(cx.theme().chart_2.opacity(0.4), 1.),
linear_color_stop(cx.theme().background.opacity(0.3), 0.),
))
.tick_margin(8),
false,
cx,
)),
)
.child(
h_flex()
.gap_x_8()
.h(px(450.))
.child(chart_container(
"Pie Chart",
PieChart::new(self.monthly_devices.clone())
.value(|d| d.desktop as f32)
.outer_radius(100.)
.color(move |d| d.color(color)),
true,
cx,
))
.child(chart_container(
"Pie Chart - Donut",
PieChart::new(self.monthly_devices.clone())
.value(|d| d.desktop as f32)
.inner_radius(60.)
.outer_radius_fn(|d| 100. - d.index as f32 * 4.)
.color(move |d| d.color(color)),
true,
cx,
))
.child(chart_container(
"Pie Chart - Pad Angle",
PieChart::new(self.monthly_devices.clone())
.value(|d| d.desktop as f32)
.inner_radius(60.)
.outer_radius(100.)
.pad_angle(4. / 100.)
.color(move |d| d.color(color)),
true,
cx,
)),
)
.child(Divider::horizontal())
.child(
h_flex()
.gap_x_8()
.h(px(400.))
.child(chart_container(
"Bar Chart",
BarChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop),
false,
cx,
))
.child(chart_container(
"Bar Chart - Mixed",
BarChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.fill(move |d| d.color(color)),
false,
cx,
))
.child(chart_container(
"Bar Chart - Label",
BarChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.label(|d| d.desktop.to_string()),
false,
cx,
)),
)
.child(Divider::horizontal())
.child(
h_flex()
.gap_x_4()
.h(px(400.))
.child(chart_container(
"Line Chart",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop),
false,
cx,
))
.child(chart_container(
"Line Chart - Linear",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.linear(),
false,
cx,
))
.child(chart_container(
"Line Chart - Step After",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.step_after(),
false,
cx,
))
.child(chart_container(
"Line Chart - Dots",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.dot()
.stroke(cx.theme().chart_5),
false,
cx,
)),
)
.child(Divider::horizontal())
.child(
h_flex()
.gap_x_4()
.h(px(400.))
.child(chart_container(
"Area Chart",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop),
false,
cx,
))
.child(chart_container(
"Area Chart - Linear",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.linear(),
false,
cx,
))
.child(chart_container(
"Area Chart - Step After",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.step_after(),
false,
cx,
))
.child(chart_container(
"Area Chart - Linear Gradient",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.fill(linear_gradient(
0.,
linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.),
linear_color_stop(cx.theme().background.opacity(0.3), 0.),
)),
false,
cx,
)),
)
}
}
pub use chart_story::*;
pub use stacked_bar_chart::StackedBarChart;

View file

@ -0,0 +1,330 @@
use gpui::{
App, AppContext, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, ParentElement,
Render, SharedString, Styled, Window, div, linear_color_stop, linear_gradient,
prelude::FluentBuilder, px,
};
use gpui_component::{
ActiveTheme, StyledExt,
chart::{AreaChart, BarChart, LineChart, PieChart},
divider::Divider,
dock::PanelControl,
h_flex, v_flex,
};
use serde::Deserialize;
use crate::{Story, chart_story::StackedBarChart};
#[derive(Clone, Deserialize)]
struct MonthlyDevice {
pub month: SharedString,
pub desktop: f64,
pub color_alpha: f32,
}
impl MonthlyDevice {
pub fn color(&self, color: Hsla) -> Hsla {
color.alpha(self.color_alpha)
}
}
#[derive(Clone, Deserialize)]
pub struct DailyDevice {
pub date: SharedString,
pub desktop: f64,
pub mobile: f64,
pub tablet: f64,
pub watch: f64,
}
pub struct ChartStory {
focus_handle: FocusHandle,
daily_devices: Vec<DailyDevice>,
monthly_devices: Vec<MonthlyDevice>,
}
impl ChartStory {
fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
let daily_devices = serde_json::from_str::<Vec<DailyDevice>>(include_str!(
"../fixtures/daily-devices.json"
))
.unwrap();
let monthly_devices = serde_json::from_str::<Vec<MonthlyDevice>>(include_str!(
"../fixtures/monthly-devices.json"
))
.unwrap();
Self {
daily_devices,
monthly_devices,
focus_handle: cx.focus_handle(),
}
}
pub fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(window, cx))
}
}
impl Story for ChartStory {
fn title() -> &'static str {
"Chart"
}
fn description() -> &'static str {
"Beautiful Charts & Graphs."
}
fn new_view(window: &mut Window, cx: &mut App) -> Entity<impl Render> {
Self::view(window, cx)
}
fn zoomable() -> Option<PanelControl> {
None
}
}
impl Focusable for ChartStory {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
fn chart_container(
title: &str,
chart: impl IntoElement,
center: bool,
cx: &mut Context<ChartStory>,
) -> impl IntoElement {
v_flex()
.flex_1()
.h_full()
.border_1()
.border_color(cx.theme().border)
.rounded_lg()
.p_4()
.child(
div()
.when(center, |this| this.text_center())
.font_semibold()
.child(title.to_string()),
)
.child(
div()
.when(center, |this| this.text_center())
.text_color(cx.theme().muted_foreground)
.text_sm()
.child("January-June 2025"),
)
.child(div().flex_1().py_4().child(chart))
.child(
div()
.when(center, |this| this.text_center())
.font_semibold()
.text_sm()
.child("Trending up by 5.2% this month"),
)
.child(
div()
.when(center, |this| this.text_center())
.text_color(cx.theme().muted_foreground)
.text_sm()
.child("Showing total visitors for the last 6 months"),
)
}
impl Render for ChartStory {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let color = cx.theme().chart_3;
v_flex()
.size_full()
.gap_y_4()
.bg(cx.theme().background)
.child(
div().h(px(400.)).child(chart_container(
"Area Chart - Stacked",
AreaChart::new(self.daily_devices.clone())
.x(|d| d.date.clone())
.y(|d| d.desktop)
.stroke(cx.theme().chart_1)
.fill(linear_gradient(
0.,
linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.),
linear_color_stop(cx.theme().background.opacity(0.3), 0.),
))
.y(|d| d.mobile)
.stroke(cx.theme().chart_2)
.fill(linear_gradient(
0.,
linear_color_stop(cx.theme().chart_2.opacity(0.4), 1.),
linear_color_stop(cx.theme().background.opacity(0.3), 0.),
))
.tick_margin(8),
false,
cx,
)),
)
.child(
h_flex()
.gap_x_8()
.h(px(450.))
.child(chart_container(
"Pie Chart",
PieChart::new(self.monthly_devices.clone())
.value(|d| d.desktop as f32)
.outer_radius(100.)
.color(move |d| d.color(color)),
true,
cx,
))
.child(chart_container(
"Pie Chart - Donut",
PieChart::new(self.monthly_devices.clone())
.value(|d| d.desktop as f32)
.inner_radius(60.)
.outer_radius_fn(|d| 100. - d.index as f32 * 4.)
.color(move |d| d.color(color)),
true,
cx,
))
.child(chart_container(
"Pie Chart - Pad Angle",
PieChart::new(self.monthly_devices.clone())
.value(|d| d.desktop as f32)
.inner_radius(60.)
.outer_radius(100.)
.pad_angle(4. / 100.)
.color(move |d| d.color(color)),
true,
cx,
)),
)
.child(Divider::horizontal())
.child(
h_flex()
.gap_x_4()
.h(px(400.))
.child(chart_container(
"Bar Chart",
BarChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop),
false,
cx,
))
.child(chart_container(
"Bar Chart - Mixed",
BarChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.fill(move |d| d.color(color)),
false,
cx,
))
.child({
let data = self.daily_devices.iter().take(8).cloned().collect();
chart_container(
"Bar Chart - Stacked",
StackedBarChart::new(data),
false,
cx,
)
})
.child(chart_container(
"Bar Chart - Label",
BarChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.label(|d| d.desktop.to_string()),
false,
cx,
)),
)
.child(Divider::horizontal())
.child(
h_flex()
.gap_x_4()
.h(px(400.))
.child(chart_container(
"Line Chart",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop),
false,
cx,
))
.child(chart_container(
"Line Chart - Linear",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.linear(),
false,
cx,
))
.child(chart_container(
"Line Chart - Step After",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.step_after(),
false,
cx,
))
.child(chart_container(
"Line Chart - Dots",
LineChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.dot()
.stroke(cx.theme().chart_5),
false,
cx,
)),
)
.child(Divider::horizontal())
.child(
h_flex()
.gap_x_4()
.h(px(400.))
.child(chart_container(
"Area Chart",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop),
false,
cx,
))
.child(chart_container(
"Area Chart - Linear",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.linear(),
false,
cx,
))
.child(chart_container(
"Area Chart - Step After",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.step_after(),
false,
cx,
))
.child(chart_container(
"Area Chart - Linear Gradient",
AreaChart::new(self.monthly_devices.clone())
.x(|d| d.month.clone())
.y(|d| d.desktop)
.fill(linear_gradient(
0.,
linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.),
linear_color_stop(cx.theme().background.opacity(0.3), 0.),
)),
false,
cx,
)),
)
}
}

View file

@ -0,0 +1,115 @@
// You can draw any chart you want by using the `Plot`.
use gpui::{App, Bounds, Pixels, TextAlign, Window, px};
use gpui_component::{
ActiveTheme, PixelsExt,
plot::{
AXIS_GAP, AxisText, Grid, IntoPlot, Plot, PlotAxis,
scale::{Scale, ScaleBand, ScaleLinear, ScaleOrdinal},
shape::{Bar, Stack, StackSeries},
},
};
use crate::chart_story::DailyDevice;
#[derive(IntoPlot)]
pub struct StackedBarChart {
data: Vec<DailyDevice>,
series: Vec<StackSeries<DailyDevice>>,
}
impl StackedBarChart {
pub fn new(data: Vec<DailyDevice>) -> Self {
// 1. Calculate the stacked data
let series = Stack::new()
.data(data.clone())
.keys(vec!["desktop", "mobile", "tablet", "watch"])
.value(move |d: &DailyDevice, key| match key {
"desktop" => Some(d.desktop as f32),
"mobile" => Some(d.mobile as f32),
"tablet" => Some(d.tablet as f32),
"watch" => Some(d.watch as f32),
_ => None,
})
.series();
Self { data, series }
}
}
impl Plot for StackedBarChart {
fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
let width = bounds.size.width.as_f32();
let height = bounds.size.height.as_f32() - AXIS_GAP;
// 2. Calculate X/Y scales
let x = ScaleBand::new(
self.data.iter().map(|v| v.date.clone()).collect(),
vec![0., width],
)
.padding_inner(0.4)
.padding_outer(0.2);
let band_width = x.band_width();
let max = self
.series
.iter()
.flat_map(|s| s.points.iter().map(|p| p.y1))
.fold(0., f32::max) as f64;
let y = ScaleLinear::new(vec![0., max], vec![height, 10.]);
// 3. Draw X axis labels
let x_label = self.data.iter().filter_map(|d| {
x.tick(&d.date.clone()).map(|x_tick| {
AxisText::new(
d.date.clone(),
x_tick + band_width / 2.,
cx.theme().muted_foreground,
)
.align(TextAlign::Center)
})
});
PlotAxis::new()
.x(height)
.x_label(x_label)
.stroke(cx.theme().border)
.paint(&bounds, window, cx);
// 4. Setup color scale
let keys = self.series.iter().map(|s| s.key.clone()).collect();
let colors = vec![
cx.theme().chart_4,
cx.theme().chart_3,
cx.theme().chart_2,
cx.theme().chart_1,
];
let ordinal = ScaleOrdinal::new(keys, colors);
// 5. Draw grid lines
Grid::new()
.y((0..=3).map(|i| height * i as f32 / 4.0).collect())
.stroke(cx.theme().border)
.dash_array(&[px(4.), px(2.)])
.paint(&bounds, window);
// 6. Draw stacked bars
for series in self.series.iter() {
let x = x.clone();
let y0 = y.clone();
let y1 = y.clone();
let key = &series.key;
let fill = ordinal.map(&key).unwrap_or(cx.theme().chart_4);
Bar::new()
.data(&series.points)
.band_width(band_width)
.x(move |d| x.tick(&d.data.date.clone()))
.y0(move |d| y0.tick(&(d.y0 as f64)).unwrap_or(height))
.y1(move |d| y1.tick(&(d.y1 as f64)))
.fill(move |_| fill)
.paint(&bounds, window, cx);
}
}
}

View file

@ -1,457 +1,639 @@
[
{
"date": "Apr 1",
"desktop": 222.0,
"mobile": 111.0
"desktop": 222,
"mobile": 111,
"tablet": 67,
"watch": 28
},
{
"date": "Apr 2",
"desktop": 97.0,
"mobile": 48.0
"desktop": 97,
"mobile": 48,
"tablet": 29,
"watch": 12
},
{
"date": "Apr 3",
"desktop": 167.0,
"mobile": 84.0
"desktop": 167,
"mobile": 84,
"tablet": 50,
"watch": 21
},
{
"date": "Apr 4",
"desktop": 242.0,
"mobile": 121.0
"desktop": 242,
"mobile": 121,
"tablet": 73,
"watch": 30
},
{
"date": "Apr 5",
"desktop": 373.0,
"mobile": 187.0
"desktop": 373,
"mobile": 187,
"tablet": 112,
"watch": 47
},
{
"date": "Apr 6",
"desktop": 301.0,
"mobile": 151.0
"desktop": 301,
"mobile": 151,
"tablet": 91,
"watch": 38
},
{
"date": "Apr 7",
"desktop": 245.0,
"mobile": 123.0
"desktop": 245,
"mobile": 123,
"tablet": 74,
"watch": 31
},
{
"date": "Apr 8",
"desktop": 409.0,
"mobile": 205.0
"desktop": 409,
"mobile": 205,
"tablet": 123,
"watch": 51
},
{
"date": "Apr 9",
"desktop": 59.0,
"mobile": 30.0
"desktop": 59,
"mobile": 30,
"tablet": 18,
"watch": 8
},
{
"date": "Apr 10",
"desktop": 261.0,
"mobile": 131.0
"desktop": 261,
"mobile": 131,
"tablet": 79,
"watch": 33
},
{
"date": "Apr 11",
"desktop": 327.0,
"mobile": 164.0
"desktop": 327,
"mobile": 164,
"tablet": 98,
"watch": 41
},
{
"date": "Apr 12",
"desktop": 292.0,
"mobile": 146.0
"desktop": 292,
"mobile": 146,
"tablet": 88,
"watch": 36
},
{
"date": "Apr 13",
"desktop": 342.0,
"mobile": 171.0
"desktop": 342,
"mobile": 171,
"tablet": 103,
"watch": 43
},
{
"date": "Apr 14",
"desktop": 137.0,
"mobile": 69.0
"desktop": 137,
"mobile": 69,
"tablet": 41,
"watch": 17
},
{
"date": "Apr 15",
"desktop": 120.0,
"mobile": 60.0
"desktop": 120,
"mobile": 60,
"tablet": 36,
"watch": 15
},
{
"date": "Apr 16",
"desktop": 138.0,
"mobile": 69.0
"desktop": 138,
"mobile": 69,
"tablet": 41,
"watch": 17
},
{
"date": "Apr 17",
"desktop": 446.0,
"mobile": 223.0
"desktop": 446,
"mobile": 223,
"tablet": 134,
"watch": 56
},
{
"date": "Apr 18",
"desktop": 364.0,
"mobile": 182.0
"desktop": 364,
"mobile": 182,
"tablet": 109,
"watch": 46
},
{
"date": "Apr 19",
"desktop": 243.0,
"mobile": 122.0
"desktop": 243,
"mobile": 122,
"tablet": 73,
"watch": 30
},
{
"date": "Apr 20",
"desktop": 89.0,
"mobile": 44.0
"desktop": 89,
"mobile": 44,
"tablet": 26,
"watch": 11
},
{
"date": "Apr 21",
"desktop": 137.0,
"mobile": 69.0
"desktop": 137,
"mobile": 69,
"tablet": 41,
"watch": 17
},
{
"date": "Apr 22",
"desktop": 224.0,
"mobile": 112.0
"desktop": 224,
"mobile": 112,
"tablet": 67,
"watch": 28
},
{
"date": "Apr 23",
"desktop": 138.0,
"mobile": 69.0
"desktop": 138,
"mobile": 69,
"tablet": 41,
"watch": 17
},
{
"date": "Apr 24",
"desktop": 387.0,
"mobile": 194.0
"desktop": 387,
"mobile": 194,
"tablet": 116,
"watch": 48
},
{
"date": "Apr 25",
"desktop": 215.0,
"mobile": 108.0
"desktop": 215,
"mobile": 108,
"tablet": 65,
"watch": 27
},
{
"date": "Apr 26",
"desktop": 75.0,
"mobile": 38.0
"desktop": 75,
"mobile": 38,
"tablet": 23,
"watch": 10
},
{
"date": "Apr 27",
"desktop": 383.0,
"mobile": 192.0
"desktop": 383,
"mobile": 192,
"tablet": 115,
"watch": 48
},
{
"date": "Apr 28",
"desktop": 122.0,
"mobile": 61.0
"desktop": 122,
"mobile": 61,
"tablet": 37,
"watch": 15
},
{
"date": "Apr 29",
"desktop": 315.0,
"mobile": 158.0
"desktop": 315,
"mobile": 158,
"tablet": 95,
"watch": 40
},
{
"date": "Apr 30",
"desktop": 454.0,
"mobile": 227.0
"desktop": 454,
"mobile": 227,
"tablet": 136,
"watch": 57
},
{
"date": "May 1",
"desktop": 165.0,
"mobile": 82.0
"desktop": 165,
"mobile": 82,
"tablet": 49,
"watch": 20
},
{
"date": "May 2",
"desktop": 293.0,
"mobile": 146.0
"desktop": 293,
"mobile": 146,
"tablet": 88,
"watch": 36
},
{
"date": "May 3",
"desktop": 247.0,
"mobile": 124.0
"desktop": 247,
"mobile": 124,
"tablet": 74,
"watch": 31
},
{
"date": "May 4",
"desktop": 385.0,
"mobile": 192.0
"desktop": 385,
"mobile": 192,
"tablet": 115,
"watch": 48
},
{
"date": "May 5",
"desktop": 481.0,
"mobile": 241.0
"desktop": 481,
"mobile": 241,
"tablet": 145,
"watch": 60
},
{
"date": "May 6",
"desktop": 498.0,
"mobile": 249.0
"desktop": 498,
"mobile": 249,
"tablet": 149,
"watch": 62
},
{
"date": "May 7",
"desktop": 388.0,
"mobile": 194.0
"desktop": 388,
"mobile": 194,
"tablet": 116,
"watch": 48
},
{
"date": "May 8",
"desktop": 149.0,
"mobile": 74.0
"desktop": 149,
"mobile": 74,
"tablet": 44,
"watch": 18
},
{
"date": "May 9",
"desktop": 227.0,
"mobile": 114.0
"desktop": 227,
"mobile": 114,
"tablet": 68,
"watch": 28
},
{
"date": "May 10",
"desktop": 293.0,
"mobile": 146.0
"desktop": 293,
"mobile": 146,
"tablet": 88,
"watch": 36
},
{
"date": "May 11",
"desktop": 335.0,
"mobile": 168.0
"desktop": 335,
"mobile": 168,
"tablet": 101,
"watch": 42
},
{
"date": "May 12",
"desktop": 197.0,
"mobile": 98.0
"desktop": 197,
"mobile": 98,
"tablet": 59,
"watch": 24
},
{
"date": "May 13",
"desktop": 197.0,
"mobile": 98.0
"desktop": 197,
"mobile": 98,
"tablet": 59,
"watch": 24
},
{
"date": "May 14",
"desktop": 448.0,
"mobile": 224.0
"desktop": 448,
"mobile": 224,
"tablet": 134,
"watch": 56
},
{
"date": "May 15",
"desktop": 473.0,
"mobile": 236.0
"desktop": 473,
"mobile": 236,
"tablet": 142,
"watch": 59
},
{
"date": "May 16",
"desktop": 338.0,
"mobile": 169.0
"desktop": 338,
"mobile": 169,
"tablet": 101,
"watch": 42
},
{
"date": "May 17",
"desktop": 499.0,
"mobile": 250.0
"desktop": 499,
"mobile": 250,
"tablet": 150,
"watch": 62
},
{
"date": "May 18",
"desktop": 315.0,
"mobile": 158.0
"desktop": 315,
"mobile": 158,
"tablet": 95,
"watch": 40
},
{
"date": "May 19",
"desktop": 235.0,
"mobile": 118.0
"desktop": 235,
"mobile": 118,
"tablet": 71,
"watch": 30
},
{
"date": "May 20",
"desktop": 177.0,
"mobile": 88.0
"desktop": 177,
"mobile": 88,
"tablet": 53,
"watch": 22
},
{
"date": "May 21",
"desktop": 82.0,
"mobile": 41.0
"desktop": 82,
"mobile": 41,
"tablet": 25,
"watch": 10
},
{
"date": "May 22",
"desktop": 81.0,
"mobile": 41.0
"desktop": 81,
"mobile": 41,
"tablet": 25,
"watch": 10
},
{
"date": "May 23",
"desktop": 252.0,
"mobile": 126.0
"desktop": 252,
"mobile": 126,
"tablet": 76,
"watch": 32
},
{
"date": "May 24",
"desktop": 294.0,
"mobile": 147.0
"desktop": 294,
"mobile": 147,
"tablet": 88,
"watch": 37
},
{
"date": "May 25",
"desktop": 201.0,
"mobile": 100.0
"desktop": 201,
"mobile": 100,
"tablet": 60,
"watch": 25
},
{
"date": "May 26",
"desktop": 213.0,
"mobile": 106.0
"desktop": 213,
"mobile": 106,
"tablet": 64,
"watch": 26
},
{
"date": "May 27",
"desktop": 420.0,
"mobile": 210.0
"desktop": 420,
"mobile": 210,
"tablet": 126,
"watch": 52
},
{
"date": "May 28",
"desktop": 233.0,
"mobile": 116.0
"desktop": 233,
"mobile": 116,
"tablet": 70,
"watch": 29
},
{
"date": "May 29",
"desktop": 78.0,
"mobile": 39.0
"desktop": 78,
"mobile": 39,
"tablet": 23,
"watch": 10
},
{
"date": "May 30",
"desktop": 340.0,
"mobile": 170.0
"desktop": 340,
"mobile": 170,
"tablet": 102,
"watch": 42
},
{
"date": "May 31",
"desktop": 178.0,
"mobile": 89.0
"desktop": 178,
"mobile": 89,
"tablet": 53,
"watch": 22
},
{
"date": "Jun 1",
"desktop": 178.0,
"mobile": 89.0
"desktop": 178,
"mobile": 89,
"tablet": 53,
"watch": 22
},
{
"date": "Jun 2",
"desktop": 470.0,
"mobile": 235.0
"desktop": 470,
"mobile": 235,
"tablet": 141,
"watch": 59
},
{
"date": "Jun 3",
"desktop": 103.0,
"mobile": 52.0
"desktop": 103,
"mobile": 52,
"tablet": 31,
"watch": 13
},
{
"date": "Jun 4",
"desktop": 439.0,
"mobile": 220.0
"desktop": 439,
"mobile": 220,
"tablet": 132,
"watch": 55
},
{
"date": "Jun 5",
"desktop": 88.0,
"mobile": 44.0
"desktop": 88,
"mobile": 44,
"tablet": 26,
"watch": 11
},
{
"date": "Jun 6",
"desktop": 294.0,
"mobile": 147.0
"desktop": 294,
"mobile": 147,
"tablet": 88,
"watch": 37
},
{
"date": "Jun 7",
"desktop": 323.0,
"mobile": 162.0
"desktop": 323,
"mobile": 162,
"tablet": 97,
"watch": 40
},
{
"date": "Jun 8",
"desktop": 385.0,
"mobile": 192.0
"desktop": 385,
"mobile": 192,
"tablet": 115,
"watch": 48
},
{
"date": "Jun 9",
"desktop": 438.0,
"mobile": 219.0
"desktop": 438,
"mobile": 219,
"tablet": 131,
"watch": 55
},
{
"date": "Jun 10",
"desktop": 155.0,
"mobile": 78.0
"desktop": 155,
"mobile": 78,
"tablet": 47,
"watch": 20
},
{
"date": "Jun 11",
"desktop": 92.0,
"mobile": 46.0
"desktop": 92,
"mobile": 46,
"tablet": 28,
"watch": 12
},
{
"date": "Jun 12",
"desktop": 492.0,
"mobile": 246.0
"desktop": 492,
"mobile": 246,
"tablet": 148,
"watch": 62
},
{
"date": "Jun 13",
"desktop": 81.0,
"mobile": 41.0
"desktop": 81,
"mobile": 41,
"tablet": 25,
"watch": 10
},
{
"date": "Jun 14",
"desktop": 426.0,
"mobile": 213.0
"desktop": 426,
"mobile": 213,
"tablet": 128,
"watch": 53
},
{
"date": "Jun 15",
"desktop": 307.0,
"mobile": 154.0
"desktop": 307,
"mobile": 154,
"tablet": 92,
"watch": 38
},
{
"date": "Jun 16",
"desktop": 371.0,
"mobile": 186.0
"desktop": 371,
"mobile": 186,
"tablet": 112,
"watch": 46
},
{
"date": "Jun 17",
"desktop": 475.0,
"mobile": 238.0
"desktop": 475,
"mobile": 238,
"tablet": 143,
"watch": 60
},
{
"date": "Jun 18",
"desktop": 107.0,
"mobile": 54.0
"desktop": 107,
"mobile": 54,
"tablet": 32,
"watch": 14
},
{
"date": "Jun 19",
"desktop": 341.0,
"mobile": 171.0
"desktop": 341,
"mobile": 171,
"tablet": 103,
"watch": 43
},
{
"date": "Jun 20",
"desktop": 408.0,
"mobile": 204.0
"desktop": 408,
"mobile": 204,
"tablet": 122,
"watch": 51
},
{
"date": "Jun 21",
"desktop": 169.0,
"mobile": 84.0
"desktop": 169,
"mobile": 84,
"tablet": 50,
"watch": 21
},
{
"date": "Jun 22",
"desktop": 317.0,
"mobile": 158.0
"desktop": 317,
"mobile": 158,
"tablet": 95,
"watch": 40
},
{
"date": "Jun 23",
"desktop": 480.0,
"mobile": 240.0
"desktop": 480,
"mobile": 240,
"tablet": 144,
"watch": 60
},
{
"date": "Jun 24",
"desktop": 132.0,
"mobile": 66.0
"desktop": 132,
"mobile": 66,
"tablet": 40,
"watch": 16
},
{
"date": "Jun 25",
"desktop": 141.0,
"mobile": 70.0
"desktop": 141,
"mobile": 70,
"tablet": 42,
"watch": 18
},
{
"date": "Jun 26",
"desktop": 434.0,
"mobile": 217.0
"desktop": 434,
"mobile": 217,
"tablet": 130,
"watch": 54
},
{
"date": "Jun 27",
"desktop": 448.0,
"mobile": 224.0
"desktop": 448,
"mobile": 224,
"tablet": 134,
"watch": 56
},
{
"date": "Jun 28",
"desktop": 149.0,
"mobile": 74.0
"desktop": 149,
"mobile": 74,
"tablet": 44,
"watch": 18
},
{
"date": "Jun 29",
"desktop": 103.0,
"mobile": 52.0
"desktop": 103,
"mobile": 52,
"tablet": 31,
"watch": 13
},
{
"date": "Jun 30",
"desktop": 446.0,
"mobile": 223.0
"desktop": 446,
"mobile": 223,
"tablet": 134,
"watch": 56
}
]
]

View file

@ -1,17 +1,17 @@
use std::rc::Rc;
use gpui::{px, App, Bounds, Hsla, Pixels, SharedString, TextAlign, Window};
use gpui::{App, Bounds, Hsla, Pixels, SharedString, TextAlign, Window, px};
use gpui_component_macros::IntoPlot;
use num_traits::{Num, ToPrimitive};
use crate::{
ActiveTheme, PixelsExt,
plot::{
AXIS_GAP, AxisText, Grid, Plot, PlotAxis,
label::Text,
scale::{Scale, ScaleBand, ScaleLinear, Sealed},
shape::Bar,
PlotAxis, AxisText, Grid, Plot, AXIS_GAP,
},
ActiveTheme, PixelsExt,
};
#[derive(IntoPlot)]
@ -148,7 +148,7 @@ where
.data(&self.data)
.band_width(band_width)
.x(move |d| x.tick(&x_fn(d)))
.y0(height)
.y0(move |_| height)
.y1(move |d| y.tick(&y_fn(d)))
.fill(move |d| fill.as_ref().map(|f| f(d)).unwrap_or(default_fill));

View file

@ -1,10 +1,12 @@
mod band;
mod linear;
mod ordinal;
mod point;
mod sealed;
pub use band::ScaleBand;
pub use linear::ScaleLinear;
pub use ordinal::ScaleOrdinal;
pub use point::ScalePoint;
pub(crate) use sealed::Sealed;

View file

@ -0,0 +1,104 @@
// @reference: https://d3js.org/d3-scale/ordinal
#[derive(Clone)]
pub struct ScaleOrdinal<D, R> {
domain: Vec<D>,
range: Vec<R>,
unknown: Option<R>,
}
impl<D, R> Default for ScaleOrdinal<D, R> {
fn default() -> Self {
Self {
domain: Vec::new(),
range: Vec::new(),
unknown: None,
}
}
}
impl<D, R> ScaleOrdinal<D, R> {
pub fn new(domain: Vec<D>, range: Vec<R>) -> Self {
Self {
domain,
range,
unknown: None,
}
}
/// Set the domain to the specified array of values.
pub fn domain(mut self, domain: Vec<D>) -> Self {
self.domain = domain;
self
}
/// Set the range of the ordinal scale to the specified array of values.
pub fn range(mut self, range: Vec<R>) -> Self {
self.range = range;
self
}
/// Set the output value of the scale for unknown input values and returns this scale.
pub fn unknown(mut self, unknown: R) -> Self {
self.unknown = Some(unknown);
self
}
}
impl<D, R> ScaleOrdinal<D, R>
where
D: PartialEq,
R: Clone,
{
/// Given a value in the input domain, returns the corresponding value in the output range.
pub fn map(&self, value: &D) -> Option<R> {
if let Some(index) = self.domain.iter().position(|v| v == value) {
if self.range.is_empty() {
None
} else {
Some(self.range[index % self.range.len()].clone())
}
} else {
self.unknown.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scale_ordinal() {
let scale = ScaleOrdinal::new(vec!["a", "b", "c", "d", "e"], vec![10, 20, 30]);
assert_eq!(scale.map(&"a"), Some(10));
assert_eq!(scale.map(&"b"), Some(20));
assert_eq!(scale.map(&"c"), Some(30));
assert_eq!(scale.map(&"d"), Some(10));
assert_eq!(scale.map(&"e"), Some(20));
assert_eq!(scale.map(&"f"), None);
}
#[test]
fn test_scale_ordinal_unknown() {
let scale = ScaleOrdinal::new(vec!["a", "b", "c"], vec![10, 20, 30]).unknown(0);
assert_eq!(scale.map(&"a"), Some(10));
assert_eq!(scale.map(&"f"), Some(0));
}
#[test]
fn test_scale_ordinal_colors() {
let keys = vec!["a", "b", "c", "d"];
let colors = vec!["#1f77b4", "#ff7f0e", "#2ca02c"];
let scale = ScaleOrdinal::new(keys, colors);
assert_eq!(scale.map(&"a"), Some("#1f77b4"));
assert_eq!(scale.map(&"b"), Some("#ff7f0e"));
assert_eq!(scale.map(&"c"), Some("#2ca02c"));
// Should cycle back to the first color
assert_eq!(scale.map(&"d"), Some("#1f77b4"));
}
}

View file

@ -3,9 +3,11 @@ mod area;
mod bar;
mod line;
mod pie;
mod stack;
pub use arc::{Arc, ArcData};
pub use area::Area;
pub use bar::Bar;
pub use line::Line;
pub use pie::Pie;
pub use stack::{Stack, StackPoint, StackSeries};

View file

@ -1,7 +1,7 @@
use gpui::{fill, point, px, App, Bounds, Hsla, PaintQuad, Pixels, Point, Window};
use gpui::{App, Bounds, Hsla, PaintQuad, Pixels, Point, Window, fill, point, px};
use crate::plot::{
label::{PlotLabel, Text, TEXT_GAP, TEXT_HEIGHT},
label::{PlotLabel, TEXT_GAP, TEXT_HEIGHT, Text},
origin_point,
};
@ -10,7 +10,7 @@ pub struct Bar<T> {
data: Vec<T>,
x: Box<dyn Fn(&T) -> Option<f32>>,
band_width: f32,
y0: f32,
y0: Box<dyn Fn(&T) -> f32>,
y1: Box<dyn Fn(&T) -> Option<f32>>,
fill: Box<dyn Fn(&T) -> Hsla>,
label: Option<Box<dyn Fn(&T, Point<Pixels>) -> Vec<Text>>>,
@ -22,7 +22,7 @@ impl<T> Default for Bar<T> {
data: Vec::new(),
x: Box::new(|_| None),
band_width: 0.,
y0: 0.,
y0: Box::new(|_| 0.),
y1: Box::new(|_| None),
fill: Box::new(|_| gpui::black()),
label: None,
@ -60,8 +60,11 @@ impl<T> Bar<T> {
}
/// Set the y0 of the Bar.
pub fn y0(mut self, y0: f32) -> Self {
self.y0 = y0;
pub fn y0<F>(mut self, y: F) -> Self
where
F: Fn(&T) -> f32 + 'static,
{
self.y0 = Box::new(y);
self
}
@ -101,18 +104,19 @@ impl<T> Bar<T> {
for v in &self.data {
let x_tick = (self.x)(v);
let y_tick = (self.y1)(v);
let y0 = (self.y0)(v);
if let (Some(x_tick), Some(y_tick)) = (x_tick, y_tick) {
let is_negative = y_tick > self.y0;
let is_negative = y_tick > y0;
let (p1, p2) = if is_negative {
(
origin_point(px(x_tick), px(self.y0), origin),
origin_point(px(x_tick), px(y0), origin),
origin_point(px(x_tick + self.band_width), px(y_tick), origin),
)
} else {
(
origin_point(px(x_tick), px(y_tick), origin),
origin_point(px(x_tick + self.band_width), px(self.y0), origin),
origin_point(px(x_tick + self.band_width), px(y0), origin),
)
};

View file

@ -0,0 +1,191 @@
// @reference: https://d3js.org/d3-shape/stack
/// Represents a stacked series data point with lower and upper values
#[derive(Clone, Debug)]
pub struct StackPoint<T> {
/// The lower value (baseline)
pub y0: f32,
/// The upper value (topline)
pub y1: f32,
/// Reference to the original data
pub data: T,
}
/// Represents a stacked series
#[derive(Clone, Debug)]
pub struct StackSeries<T> {
/// The key for this series
pub key: String,
/// The index of this series
pub index: usize,
/// The points in this series
pub points: Vec<StackPoint<T>>,
}
#[allow(clippy::type_complexity)]
pub struct Stack<T> {
data: Vec<T>,
keys: Vec<String>,
value: Box<dyn Fn(&T, &str) -> Option<f32>>,
}
impl<T: Clone> Default for Stack<T> {
fn default() -> Self {
Self {
data: Vec::new(),
keys: Vec::new(),
value: Box::new(|_, _| None),
}
}
}
impl<T: Clone> Stack<T> {
pub fn new() -> Self {
Self::default()
}
/// Set the data to be stacked
pub fn data<I>(mut self, data: I) -> Self
where
I: IntoIterator<Item = T>,
{
self.data = data.into_iter().collect();
self
}
/// Set the keys (series) for stacking
pub fn keys<I, S>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.keys = keys.into_iter().map(|s| s.into()).collect();
self
}
/// Set the value accessor function
pub fn value<F>(mut self, value: F) -> Self
where
F: Fn(&T, &str) -> Option<f32> + 'static,
{
self.value = Box::new(value);
self
}
/// Compute the stacked series
pub fn series(&self) -> Vec<StackSeries<T>> {
if self.data.is_empty() || self.keys.is_empty() {
return Vec::new();
}
let n = self.data.len(); // number of data points
let m = self.keys.len(); // number of series
// Extract values into a 2D matrix: series x data points
let mut matrix: Vec<Vec<f32>> = Vec::with_capacity(m);
for key in &self.keys {
let mut series_values = Vec::with_capacity(n);
for datum in &self.data {
let value = (self.value)(datum, key).unwrap_or(0.0);
series_values.push(value);
}
matrix.push(series_values);
}
// Use the natural key order for stacking
let order: Vec<usize> = (0..m).collect();
// Initialize stacks with zeros
let mut stacks: Vec<Vec<(f32, f32)>> = vec![vec![(0.0, 0.0); n]; m];
// Compute the stacks based on order
for j in 0..n {
let mut y0 = 0.0;
for &i in &order {
let y1 = y0 + matrix[i][j];
stacks[i][j] = (y0, y1);
y0 = y1;
}
}
// Build the result series
let mut result = Vec::with_capacity(m);
for (i, key) in self.keys.iter().enumerate() {
let points = self
.data
.iter()
.enumerate()
.map(|(j, datum)| StackPoint {
y0: stacks[i][j].0,
y1: stacks[i][j].1,
data: datum.clone(),
})
.collect();
result.push(StackSeries {
key: key.clone(),
index: i,
points,
});
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug)]
struct SalesData {
#[allow(dead_code)]
date: String,
apples: f32,
bananas: f32,
cherries: f32,
}
#[test]
fn test_basic_stack() {
let data = vec![
SalesData {
date: "Jan".to_string(),
apples: 10.0,
bananas: 20.0,
cherries: 30.0,
},
SalesData {
date: "Feb".to_string(),
apples: 15.0,
bananas: 25.0,
cherries: 35.0,
},
];
let stack = Stack::new()
.data(data)
.keys(vec!["apples", "bananas", "cherries"])
.value(|d, key| match key {
"apples" => Some(d.apples),
"bananas" => Some(d.bananas),
"cherries" => Some(d.cherries),
_ => None,
});
let series = stack.series();
assert_eq!(series.len(), 3);
assert_eq!(series[0].key, "apples");
assert_eq!(series[0].points[0].y0, 0.0);
assert_eq!(series[0].points[0].y1, 10.0);
assert_eq!(series[1].key, "bananas");
assert_eq!(series[1].points[0].y0, 10.0);
assert_eq!(series[1].points[0].y1, 30.0);
assert_eq!(series[2].key, "cherries");
assert_eq!(series[2].points[0].y0, 30.0);
assert_eq!(series[2].points[0].y1, 60.0);
}
}

View file

@ -0,0 +1,250 @@
---
title: Plot
description: A low-level plotting library for creating custom charts and data visualizations.
---
# Plot
The `plot` module provides low-level building blocks for creating custom charts. It includes scales, shapes, and utilities that power the high-level `Chart` components.
## Import
```rust
use gpui_component::plot::{
scale::{Scale, ScaleLinear, ScaleBand, ScalePoint, ScaleOrdinal},
shape::{Bar, Stack, Line, Area, Pie, Arc},
PlotAxis, AxisText
};
```
## Scales
Scales map a dimension of abstract data to a visual representation.
### ScaleLinear
Maps a continuous quantitative domain to a continuous range.
```rust
let scale = ScaleLinear::new(
vec![0., 100.], // Domain (data values)
vec![0., 500.] // Range (pixel coordinates)
);
scale.tick(&50.); // Returns pixel position
```
### ScaleBand
Maps a discrete domain to a continuous range, useful for bar charts.
```rust
let scale = ScaleBand::new(
vec!["A", "B", "C"], // Domain
vec![0., 300.] // Range
)
.padding_inner(0.1)
.padding_outer(0.1);
scale.band_width(); // Returns width of each band
scale.tick(&"A"); // Returns start position of band "A"
```
### ScalePoint
Maps a discrete domain to a set of points in a continuous range, useful for scatter plots or line charts with categorical axes.
```rust
let scale = ScalePoint::new(
vec!["A", "B", "C"], // Domain
vec![0., 300.] // Range
);
scale.tick(&"A"); // Returns position of point "A"
```
### ScaleOrdinal
Maps a discrete domain to a discrete range.
```rust
let scale = ScaleOrdinal::new(
vec!["A", "B", "C"], // Domain
vec![color1, color2, color3] // Range
);
scale.map(&"A"); // Returns color1
```
## Shapes
### Bar
Renders a bar shape, commonly used in bar charts.
```rust
Bar::new()
.data(data)
.band_width(30.)
.x(|d| x_scale.tick(&d.category))
.y0(|d| y_scale.tick(&0.).unwrap())
.y1(|d| y_scale.tick(&d.value))
.fill(|d| color_scale.map(&d.category))
.paint(&bounds, window, cx);
```
### Line
Renders a line shape, commonly used in line charts.
```rust
Line::new()
.data(data)
.x(|d| x_scale.tick(&d.date))
.y(|d| y_scale.tick(&d.value))
.stroke(cx.theme().chart_1)
.stroke_width(px(2.))
.paint(&bounds, window);
```
#### Line with Dots
Supports rendering dots at data points.
```rust
Line::new()
.data(data)
.x(|d| x_scale.tick(&d.date))
.y(|d| y_scale.tick(&d.value))
.dot()
.dot_size(px(4.))
.paint(&bounds, window);
```
### Area
Renders an area shape, commonly used in area charts.
```rust
Area::new()
.data(data)
.x(|d| x_scale.tick(&d.date))
.y0(height) // Baseline
.y1(|d| y_scale.tick(&d.value))
.fill(cx.theme().chart_1.opacity(0.5))
.stroke(cx.theme().chart_1)
.paint(&bounds, window);
```
### Pie & Arc
Renders pie charts and donut charts using `Pie` layout and `Arc` shape.
```rust
// 1. Compute pie layout
let pie = Pie::new()
.value(|d| Some(d.value))
.pad_angle(0.05);
let arcs = pie.arcs(&data);
// 2. Render arcs
let arc_shape = Arc::new()
.inner_radius(0.)
.outer_radius(100.);
for arc_data in arcs {
arc_shape.paint(
&arc_data,
color_scale.map(&arc_data.data.category), // Color
None, // Override inner radius
None, // Override outer radius
&bounds,
window
);
}
```
### Stack
Computes stacked layout for data series.
```rust
let stack = Stack::new()
.data(data)
.keys(vec!["series1", "series2"])
.value(|d, key| match key {
"series1" => Some(d.val1),
"series2" => Some(d.val2),
_ => None
});
let series = stack.series(); // Returns Vec<StackSeries<T>>
```
## Components
### PlotAxis
Renders chart axes with labels and ticks.
```rust
PlotAxis::new()
.x(height) // Y position for X axis
.x_label(labels) // Iterator of AxisText
.stroke(cx.theme().border)
.paint(&bounds, window, cx);
```
## Examples
### Custom Bar Chart Implementation
Here's how to implement a custom stacked bar chart using low-level plot primitives:
```rust
struct StackedBarChart {
data: Vec<DailyDevice>,
series: Vec<StackSeries<DailyDevice>>,
}
impl StackedBarChart {
pub fn new(data: Vec<DailyDevice>) -> Self {
let series = Stack::new()
.data(data.clone())
.keys(vec!["desktop", "mobile"])
.value(|d, key| match key {
"desktop" => Some(d.desktop),
"mobile" => Some(d.mobile),
_ => None,
})
.series();
Self { data, series }
}
}
impl Plot for StackedBarChart {
fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
// 1. Setup Scales
let x = ScaleBand::new(
self.data.iter().map(|v| v.date.clone()).collect(),
vec![0., width],
);
let y = ScaleLinear::new(vec![0., max_value], vec![height, 0.]);
// 2. Draw Axis
// ... (axis rendering logic)
// 3. Draw Stacked Bars
let bar = Bar::new()
.stack_data(&self.series)
.band_width(x.band_width())
.x(move |d| x.tick(&d.data.date))
.fill(move |_| cx.theme().chart_1);
bar.paint(&bounds, window, cx);
}
}
```