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:
parent
55da14edb2
commit
0d167f61e5
11 changed files with 1380 additions and 511 deletions
|
|
@ -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;
|
||||
|
|
|
|||
330
crates/story/src/chart_story/chart_story.rs
Normal file
330
crates/story/src/chart_story/chart_story.rs
Normal 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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
115
crates/story/src/chart_story/stacked_bar_chart.rs
Normal file
115
crates/story/src/chart_story/stacked_bar_chart.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
104
crates/ui/src/plot/scale/ordinal.rs
Normal file
104
crates/ui/src/plot/scale/ordinal.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
};
|
||||
|
||||
|
|
|
|||
191
crates/ui/src/plot/shape/stack.rs
Normal file
191
crates/ui/src/plot/shape/stack.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
250
docs/docs/components/plot.md
Normal file
250
docs/docs/components/plot.md
Normal 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Reference in a new issue