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::{
|
mod chart_story;
|
||||||
App, AppContext, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, ParentElement,
|
mod stacked_bar_chart;
|
||||||
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;
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
pub use chart_story::*;
|
||||||
struct MonthlyDevice {
|
pub use stacked_bar_chart::StackedBarChart;
|
||||||
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,
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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",
|
"date": "Apr 1",
|
||||||
"desktop": 222.0,
|
"desktop": 222,
|
||||||
"mobile": 111.0
|
"mobile": 111,
|
||||||
|
"tablet": 67,
|
||||||
|
"watch": 28
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 2",
|
"date": "Apr 2",
|
||||||
"desktop": 97.0,
|
"desktop": 97,
|
||||||
"mobile": 48.0
|
"mobile": 48,
|
||||||
|
"tablet": 29,
|
||||||
|
"watch": 12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 3",
|
"date": "Apr 3",
|
||||||
"desktop": 167.0,
|
"desktop": 167,
|
||||||
"mobile": 84.0
|
"mobile": 84,
|
||||||
|
"tablet": 50,
|
||||||
|
"watch": 21
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 4",
|
"date": "Apr 4",
|
||||||
"desktop": 242.0,
|
"desktop": 242,
|
||||||
"mobile": 121.0
|
"mobile": 121,
|
||||||
|
"tablet": 73,
|
||||||
|
"watch": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 5",
|
"date": "Apr 5",
|
||||||
"desktop": 373.0,
|
"desktop": 373,
|
||||||
"mobile": 187.0
|
"mobile": 187,
|
||||||
|
"tablet": 112,
|
||||||
|
"watch": 47
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 6",
|
"date": "Apr 6",
|
||||||
"desktop": 301.0,
|
"desktop": 301,
|
||||||
"mobile": 151.0
|
"mobile": 151,
|
||||||
|
"tablet": 91,
|
||||||
|
"watch": 38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 7",
|
"date": "Apr 7",
|
||||||
"desktop": 245.0,
|
"desktop": 245,
|
||||||
"mobile": 123.0
|
"mobile": 123,
|
||||||
|
"tablet": 74,
|
||||||
|
"watch": 31
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 8",
|
"date": "Apr 8",
|
||||||
"desktop": 409.0,
|
"desktop": 409,
|
||||||
"mobile": 205.0
|
"mobile": 205,
|
||||||
|
"tablet": 123,
|
||||||
|
"watch": 51
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 9",
|
"date": "Apr 9",
|
||||||
"desktop": 59.0,
|
"desktop": 59,
|
||||||
"mobile": 30.0
|
"mobile": 30,
|
||||||
|
"tablet": 18,
|
||||||
|
"watch": 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 10",
|
"date": "Apr 10",
|
||||||
"desktop": 261.0,
|
"desktop": 261,
|
||||||
"mobile": 131.0
|
"mobile": 131,
|
||||||
|
"tablet": 79,
|
||||||
|
"watch": 33
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 11",
|
"date": "Apr 11",
|
||||||
"desktop": 327.0,
|
"desktop": 327,
|
||||||
"mobile": 164.0
|
"mobile": 164,
|
||||||
|
"tablet": 98,
|
||||||
|
"watch": 41
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 12",
|
"date": "Apr 12",
|
||||||
"desktop": 292.0,
|
"desktop": 292,
|
||||||
"mobile": 146.0
|
"mobile": 146,
|
||||||
|
"tablet": 88,
|
||||||
|
"watch": 36
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 13",
|
"date": "Apr 13",
|
||||||
"desktop": 342.0,
|
"desktop": 342,
|
||||||
"mobile": 171.0
|
"mobile": 171,
|
||||||
|
"tablet": 103,
|
||||||
|
"watch": 43
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 14",
|
"date": "Apr 14",
|
||||||
"desktop": 137.0,
|
"desktop": 137,
|
||||||
"mobile": 69.0
|
"mobile": 69,
|
||||||
|
"tablet": 41,
|
||||||
|
"watch": 17
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 15",
|
"date": "Apr 15",
|
||||||
"desktop": 120.0,
|
"desktop": 120,
|
||||||
"mobile": 60.0
|
"mobile": 60,
|
||||||
|
"tablet": 36,
|
||||||
|
"watch": 15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 16",
|
"date": "Apr 16",
|
||||||
"desktop": 138.0,
|
"desktop": 138,
|
||||||
"mobile": 69.0
|
"mobile": 69,
|
||||||
|
"tablet": 41,
|
||||||
|
"watch": 17
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 17",
|
"date": "Apr 17",
|
||||||
"desktop": 446.0,
|
"desktop": 446,
|
||||||
"mobile": 223.0
|
"mobile": 223,
|
||||||
|
"tablet": 134,
|
||||||
|
"watch": 56
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 18",
|
"date": "Apr 18",
|
||||||
"desktop": 364.0,
|
"desktop": 364,
|
||||||
"mobile": 182.0
|
"mobile": 182,
|
||||||
|
"tablet": 109,
|
||||||
|
"watch": 46
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 19",
|
"date": "Apr 19",
|
||||||
"desktop": 243.0,
|
"desktop": 243,
|
||||||
"mobile": 122.0
|
"mobile": 122,
|
||||||
|
"tablet": 73,
|
||||||
|
"watch": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 20",
|
"date": "Apr 20",
|
||||||
"desktop": 89.0,
|
"desktop": 89,
|
||||||
"mobile": 44.0
|
"mobile": 44,
|
||||||
|
"tablet": 26,
|
||||||
|
"watch": 11
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 21",
|
"date": "Apr 21",
|
||||||
"desktop": 137.0,
|
"desktop": 137,
|
||||||
"mobile": 69.0
|
"mobile": 69,
|
||||||
|
"tablet": 41,
|
||||||
|
"watch": 17
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 22",
|
"date": "Apr 22",
|
||||||
"desktop": 224.0,
|
"desktop": 224,
|
||||||
"mobile": 112.0
|
"mobile": 112,
|
||||||
|
"tablet": 67,
|
||||||
|
"watch": 28
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 23",
|
"date": "Apr 23",
|
||||||
"desktop": 138.0,
|
"desktop": 138,
|
||||||
"mobile": 69.0
|
"mobile": 69,
|
||||||
|
"tablet": 41,
|
||||||
|
"watch": 17
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 24",
|
"date": "Apr 24",
|
||||||
"desktop": 387.0,
|
"desktop": 387,
|
||||||
"mobile": 194.0
|
"mobile": 194,
|
||||||
|
"tablet": 116,
|
||||||
|
"watch": 48
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 25",
|
"date": "Apr 25",
|
||||||
"desktop": 215.0,
|
"desktop": 215,
|
||||||
"mobile": 108.0
|
"mobile": 108,
|
||||||
|
"tablet": 65,
|
||||||
|
"watch": 27
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 26",
|
"date": "Apr 26",
|
||||||
"desktop": 75.0,
|
"desktop": 75,
|
||||||
"mobile": 38.0
|
"mobile": 38,
|
||||||
|
"tablet": 23,
|
||||||
|
"watch": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 27",
|
"date": "Apr 27",
|
||||||
"desktop": 383.0,
|
"desktop": 383,
|
||||||
"mobile": 192.0
|
"mobile": 192,
|
||||||
|
"tablet": 115,
|
||||||
|
"watch": 48
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 28",
|
"date": "Apr 28",
|
||||||
"desktop": 122.0,
|
"desktop": 122,
|
||||||
"mobile": 61.0
|
"mobile": 61,
|
||||||
|
"tablet": 37,
|
||||||
|
"watch": 15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 29",
|
"date": "Apr 29",
|
||||||
"desktop": 315.0,
|
"desktop": 315,
|
||||||
"mobile": 158.0
|
"mobile": 158,
|
||||||
|
"tablet": 95,
|
||||||
|
"watch": 40
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Apr 30",
|
"date": "Apr 30",
|
||||||
"desktop": 454.0,
|
"desktop": 454,
|
||||||
"mobile": 227.0
|
"mobile": 227,
|
||||||
|
"tablet": 136,
|
||||||
|
"watch": 57
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 1",
|
"date": "May 1",
|
||||||
"desktop": 165.0,
|
"desktop": 165,
|
||||||
"mobile": 82.0
|
"mobile": 82,
|
||||||
|
"tablet": 49,
|
||||||
|
"watch": 20
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 2",
|
"date": "May 2",
|
||||||
"desktop": 293.0,
|
"desktop": 293,
|
||||||
"mobile": 146.0
|
"mobile": 146,
|
||||||
|
"tablet": 88,
|
||||||
|
"watch": 36
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 3",
|
"date": "May 3",
|
||||||
"desktop": 247.0,
|
"desktop": 247,
|
||||||
"mobile": 124.0
|
"mobile": 124,
|
||||||
|
"tablet": 74,
|
||||||
|
"watch": 31
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 4",
|
"date": "May 4",
|
||||||
"desktop": 385.0,
|
"desktop": 385,
|
||||||
"mobile": 192.0
|
"mobile": 192,
|
||||||
|
"tablet": 115,
|
||||||
|
"watch": 48
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 5",
|
"date": "May 5",
|
||||||
"desktop": 481.0,
|
"desktop": 481,
|
||||||
"mobile": 241.0
|
"mobile": 241,
|
||||||
|
"tablet": 145,
|
||||||
|
"watch": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 6",
|
"date": "May 6",
|
||||||
"desktop": 498.0,
|
"desktop": 498,
|
||||||
"mobile": 249.0
|
"mobile": 249,
|
||||||
|
"tablet": 149,
|
||||||
|
"watch": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 7",
|
"date": "May 7",
|
||||||
"desktop": 388.0,
|
"desktop": 388,
|
||||||
"mobile": 194.0
|
"mobile": 194,
|
||||||
|
"tablet": 116,
|
||||||
|
"watch": 48
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 8",
|
"date": "May 8",
|
||||||
"desktop": 149.0,
|
"desktop": 149,
|
||||||
"mobile": 74.0
|
"mobile": 74,
|
||||||
|
"tablet": 44,
|
||||||
|
"watch": 18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 9",
|
"date": "May 9",
|
||||||
"desktop": 227.0,
|
"desktop": 227,
|
||||||
"mobile": 114.0
|
"mobile": 114,
|
||||||
|
"tablet": 68,
|
||||||
|
"watch": 28
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 10",
|
"date": "May 10",
|
||||||
"desktop": 293.0,
|
"desktop": 293,
|
||||||
"mobile": 146.0
|
"mobile": 146,
|
||||||
|
"tablet": 88,
|
||||||
|
"watch": 36
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 11",
|
"date": "May 11",
|
||||||
"desktop": 335.0,
|
"desktop": 335,
|
||||||
"mobile": 168.0
|
"mobile": 168,
|
||||||
|
"tablet": 101,
|
||||||
|
"watch": 42
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 12",
|
"date": "May 12",
|
||||||
"desktop": 197.0,
|
"desktop": 197,
|
||||||
"mobile": 98.0
|
"mobile": 98,
|
||||||
|
"tablet": 59,
|
||||||
|
"watch": 24
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 13",
|
"date": "May 13",
|
||||||
"desktop": 197.0,
|
"desktop": 197,
|
||||||
"mobile": 98.0
|
"mobile": 98,
|
||||||
|
"tablet": 59,
|
||||||
|
"watch": 24
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 14",
|
"date": "May 14",
|
||||||
"desktop": 448.0,
|
"desktop": 448,
|
||||||
"mobile": 224.0
|
"mobile": 224,
|
||||||
|
"tablet": 134,
|
||||||
|
"watch": 56
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 15",
|
"date": "May 15",
|
||||||
"desktop": 473.0,
|
"desktop": 473,
|
||||||
"mobile": 236.0
|
"mobile": 236,
|
||||||
|
"tablet": 142,
|
||||||
|
"watch": 59
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 16",
|
"date": "May 16",
|
||||||
"desktop": 338.0,
|
"desktop": 338,
|
||||||
"mobile": 169.0
|
"mobile": 169,
|
||||||
|
"tablet": 101,
|
||||||
|
"watch": 42
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 17",
|
"date": "May 17",
|
||||||
"desktop": 499.0,
|
"desktop": 499,
|
||||||
"mobile": 250.0
|
"mobile": 250,
|
||||||
|
"tablet": 150,
|
||||||
|
"watch": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 18",
|
"date": "May 18",
|
||||||
"desktop": 315.0,
|
"desktop": 315,
|
||||||
"mobile": 158.0
|
"mobile": 158,
|
||||||
|
"tablet": 95,
|
||||||
|
"watch": 40
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 19",
|
"date": "May 19",
|
||||||
"desktop": 235.0,
|
"desktop": 235,
|
||||||
"mobile": 118.0
|
"mobile": 118,
|
||||||
|
"tablet": 71,
|
||||||
|
"watch": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 20",
|
"date": "May 20",
|
||||||
"desktop": 177.0,
|
"desktop": 177,
|
||||||
"mobile": 88.0
|
"mobile": 88,
|
||||||
|
"tablet": 53,
|
||||||
|
"watch": 22
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 21",
|
"date": "May 21",
|
||||||
"desktop": 82.0,
|
"desktop": 82,
|
||||||
"mobile": 41.0
|
"mobile": 41,
|
||||||
|
"tablet": 25,
|
||||||
|
"watch": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 22",
|
"date": "May 22",
|
||||||
"desktop": 81.0,
|
"desktop": 81,
|
||||||
"mobile": 41.0
|
"mobile": 41,
|
||||||
|
"tablet": 25,
|
||||||
|
"watch": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 23",
|
"date": "May 23",
|
||||||
"desktop": 252.0,
|
"desktop": 252,
|
||||||
"mobile": 126.0
|
"mobile": 126,
|
||||||
|
"tablet": 76,
|
||||||
|
"watch": 32
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 24",
|
"date": "May 24",
|
||||||
"desktop": 294.0,
|
"desktop": 294,
|
||||||
"mobile": 147.0
|
"mobile": 147,
|
||||||
|
"tablet": 88,
|
||||||
|
"watch": 37
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 25",
|
"date": "May 25",
|
||||||
"desktop": 201.0,
|
"desktop": 201,
|
||||||
"mobile": 100.0
|
"mobile": 100,
|
||||||
|
"tablet": 60,
|
||||||
|
"watch": 25
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 26",
|
"date": "May 26",
|
||||||
"desktop": 213.0,
|
"desktop": 213,
|
||||||
"mobile": 106.0
|
"mobile": 106,
|
||||||
|
"tablet": 64,
|
||||||
|
"watch": 26
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 27",
|
"date": "May 27",
|
||||||
"desktop": 420.0,
|
"desktop": 420,
|
||||||
"mobile": 210.0
|
"mobile": 210,
|
||||||
|
"tablet": 126,
|
||||||
|
"watch": 52
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 28",
|
"date": "May 28",
|
||||||
"desktop": 233.0,
|
"desktop": 233,
|
||||||
"mobile": 116.0
|
"mobile": 116,
|
||||||
|
"tablet": 70,
|
||||||
|
"watch": 29
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 29",
|
"date": "May 29",
|
||||||
"desktop": 78.0,
|
"desktop": 78,
|
||||||
"mobile": 39.0
|
"mobile": 39,
|
||||||
|
"tablet": 23,
|
||||||
|
"watch": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 30",
|
"date": "May 30",
|
||||||
"desktop": 340.0,
|
"desktop": 340,
|
||||||
"mobile": 170.0
|
"mobile": 170,
|
||||||
|
"tablet": 102,
|
||||||
|
"watch": 42
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "May 31",
|
"date": "May 31",
|
||||||
"desktop": 178.0,
|
"desktop": 178,
|
||||||
"mobile": 89.0
|
"mobile": 89,
|
||||||
|
"tablet": 53,
|
||||||
|
"watch": 22
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 1",
|
"date": "Jun 1",
|
||||||
"desktop": 178.0,
|
"desktop": 178,
|
||||||
"mobile": 89.0
|
"mobile": 89,
|
||||||
|
"tablet": 53,
|
||||||
|
"watch": 22
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 2",
|
"date": "Jun 2",
|
||||||
"desktop": 470.0,
|
"desktop": 470,
|
||||||
"mobile": 235.0
|
"mobile": 235,
|
||||||
|
"tablet": 141,
|
||||||
|
"watch": 59
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 3",
|
"date": "Jun 3",
|
||||||
"desktop": 103.0,
|
"desktop": 103,
|
||||||
"mobile": 52.0
|
"mobile": 52,
|
||||||
|
"tablet": 31,
|
||||||
|
"watch": 13
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 4",
|
"date": "Jun 4",
|
||||||
"desktop": 439.0,
|
"desktop": 439,
|
||||||
"mobile": 220.0
|
"mobile": 220,
|
||||||
|
"tablet": 132,
|
||||||
|
"watch": 55
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 5",
|
"date": "Jun 5",
|
||||||
"desktop": 88.0,
|
"desktop": 88,
|
||||||
"mobile": 44.0
|
"mobile": 44,
|
||||||
|
"tablet": 26,
|
||||||
|
"watch": 11
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 6",
|
"date": "Jun 6",
|
||||||
"desktop": 294.0,
|
"desktop": 294,
|
||||||
"mobile": 147.0
|
"mobile": 147,
|
||||||
|
"tablet": 88,
|
||||||
|
"watch": 37
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 7",
|
"date": "Jun 7",
|
||||||
"desktop": 323.0,
|
"desktop": 323,
|
||||||
"mobile": 162.0
|
"mobile": 162,
|
||||||
|
"tablet": 97,
|
||||||
|
"watch": 40
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 8",
|
"date": "Jun 8",
|
||||||
"desktop": 385.0,
|
"desktop": 385,
|
||||||
"mobile": 192.0
|
"mobile": 192,
|
||||||
|
"tablet": 115,
|
||||||
|
"watch": 48
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 9",
|
"date": "Jun 9",
|
||||||
"desktop": 438.0,
|
"desktop": 438,
|
||||||
"mobile": 219.0
|
"mobile": 219,
|
||||||
|
"tablet": 131,
|
||||||
|
"watch": 55
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 10",
|
"date": "Jun 10",
|
||||||
"desktop": 155.0,
|
"desktop": 155,
|
||||||
"mobile": 78.0
|
"mobile": 78,
|
||||||
|
"tablet": 47,
|
||||||
|
"watch": 20
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 11",
|
"date": "Jun 11",
|
||||||
"desktop": 92.0,
|
"desktop": 92,
|
||||||
"mobile": 46.0
|
"mobile": 46,
|
||||||
|
"tablet": 28,
|
||||||
|
"watch": 12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 12",
|
"date": "Jun 12",
|
||||||
"desktop": 492.0,
|
"desktop": 492,
|
||||||
"mobile": 246.0
|
"mobile": 246,
|
||||||
|
"tablet": 148,
|
||||||
|
"watch": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 13",
|
"date": "Jun 13",
|
||||||
"desktop": 81.0,
|
"desktop": 81,
|
||||||
"mobile": 41.0
|
"mobile": 41,
|
||||||
|
"tablet": 25,
|
||||||
|
"watch": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 14",
|
"date": "Jun 14",
|
||||||
"desktop": 426.0,
|
"desktop": 426,
|
||||||
"mobile": 213.0
|
"mobile": 213,
|
||||||
|
"tablet": 128,
|
||||||
|
"watch": 53
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 15",
|
"date": "Jun 15",
|
||||||
"desktop": 307.0,
|
"desktop": 307,
|
||||||
"mobile": 154.0
|
"mobile": 154,
|
||||||
|
"tablet": 92,
|
||||||
|
"watch": 38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 16",
|
"date": "Jun 16",
|
||||||
"desktop": 371.0,
|
"desktop": 371,
|
||||||
"mobile": 186.0
|
"mobile": 186,
|
||||||
|
"tablet": 112,
|
||||||
|
"watch": 46
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 17",
|
"date": "Jun 17",
|
||||||
"desktop": 475.0,
|
"desktop": 475,
|
||||||
"mobile": 238.0
|
"mobile": 238,
|
||||||
|
"tablet": 143,
|
||||||
|
"watch": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 18",
|
"date": "Jun 18",
|
||||||
"desktop": 107.0,
|
"desktop": 107,
|
||||||
"mobile": 54.0
|
"mobile": 54,
|
||||||
|
"tablet": 32,
|
||||||
|
"watch": 14
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 19",
|
"date": "Jun 19",
|
||||||
"desktop": 341.0,
|
"desktop": 341,
|
||||||
"mobile": 171.0
|
"mobile": 171,
|
||||||
|
"tablet": 103,
|
||||||
|
"watch": 43
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 20",
|
"date": "Jun 20",
|
||||||
"desktop": 408.0,
|
"desktop": 408,
|
||||||
"mobile": 204.0
|
"mobile": 204,
|
||||||
|
"tablet": 122,
|
||||||
|
"watch": 51
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 21",
|
"date": "Jun 21",
|
||||||
"desktop": 169.0,
|
"desktop": 169,
|
||||||
"mobile": 84.0
|
"mobile": 84,
|
||||||
|
"tablet": 50,
|
||||||
|
"watch": 21
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 22",
|
"date": "Jun 22",
|
||||||
"desktop": 317.0,
|
"desktop": 317,
|
||||||
"mobile": 158.0
|
"mobile": 158,
|
||||||
|
"tablet": 95,
|
||||||
|
"watch": 40
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 23",
|
"date": "Jun 23",
|
||||||
"desktop": 480.0,
|
"desktop": 480,
|
||||||
"mobile": 240.0
|
"mobile": 240,
|
||||||
|
"tablet": 144,
|
||||||
|
"watch": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 24",
|
"date": "Jun 24",
|
||||||
"desktop": 132.0,
|
"desktop": 132,
|
||||||
"mobile": 66.0
|
"mobile": 66,
|
||||||
|
"tablet": 40,
|
||||||
|
"watch": 16
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 25",
|
"date": "Jun 25",
|
||||||
"desktop": 141.0,
|
"desktop": 141,
|
||||||
"mobile": 70.0
|
"mobile": 70,
|
||||||
|
"tablet": 42,
|
||||||
|
"watch": 18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 26",
|
"date": "Jun 26",
|
||||||
"desktop": 434.0,
|
"desktop": 434,
|
||||||
"mobile": 217.0
|
"mobile": 217,
|
||||||
|
"tablet": 130,
|
||||||
|
"watch": 54
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 27",
|
"date": "Jun 27",
|
||||||
"desktop": 448.0,
|
"desktop": 448,
|
||||||
"mobile": 224.0
|
"mobile": 224,
|
||||||
|
"tablet": 134,
|
||||||
|
"watch": 56
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 28",
|
"date": "Jun 28",
|
||||||
"desktop": 149.0,
|
"desktop": 149,
|
||||||
"mobile": 74.0
|
"mobile": 74,
|
||||||
|
"tablet": 44,
|
||||||
|
"watch": 18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 29",
|
"date": "Jun 29",
|
||||||
"desktop": 103.0,
|
"desktop": 103,
|
||||||
"mobile": 52.0
|
"mobile": 52,
|
||||||
|
"tablet": 31,
|
||||||
|
"watch": 13
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Jun 30",
|
"date": "Jun 30",
|
||||||
"desktop": 446.0,
|
"desktop": 446,
|
||||||
"mobile": 223.0
|
"mobile": 223,
|
||||||
|
"tablet": 134,
|
||||||
|
"watch": 56
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
use std::rc::Rc;
|
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 gpui_component_macros::IntoPlot;
|
||||||
use num_traits::{Num, ToPrimitive};
|
use num_traits::{Num, ToPrimitive};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
ActiveTheme, PixelsExt,
|
||||||
plot::{
|
plot::{
|
||||||
|
AXIS_GAP, AxisText, Grid, Plot, PlotAxis,
|
||||||
label::Text,
|
label::Text,
|
||||||
scale::{Scale, ScaleBand, ScaleLinear, Sealed},
|
scale::{Scale, ScaleBand, ScaleLinear, Sealed},
|
||||||
shape::Bar,
|
shape::Bar,
|
||||||
PlotAxis, AxisText, Grid, Plot, AXIS_GAP,
|
|
||||||
},
|
},
|
||||||
ActiveTheme, PixelsExt,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(IntoPlot)]
|
#[derive(IntoPlot)]
|
||||||
|
|
@ -148,7 +148,7 @@ where
|
||||||
.data(&self.data)
|
.data(&self.data)
|
||||||
.band_width(band_width)
|
.band_width(band_width)
|
||||||
.x(move |d| x.tick(&x_fn(d)))
|
.x(move |d| x.tick(&x_fn(d)))
|
||||||
.y0(height)
|
.y0(move |_| height)
|
||||||
.y1(move |d| y.tick(&y_fn(d)))
|
.y1(move |d| y.tick(&y_fn(d)))
|
||||||
.fill(move |d| fill.as_ref().map(|f| f(d)).unwrap_or(default_fill));
|
.fill(move |d| fill.as_ref().map(|f| f(d)).unwrap_or(default_fill));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
mod band;
|
mod band;
|
||||||
mod linear;
|
mod linear;
|
||||||
|
mod ordinal;
|
||||||
mod point;
|
mod point;
|
||||||
mod sealed;
|
mod sealed;
|
||||||
|
|
||||||
pub use band::ScaleBand;
|
pub use band::ScaleBand;
|
||||||
pub use linear::ScaleLinear;
|
pub use linear::ScaleLinear;
|
||||||
|
pub use ordinal::ScaleOrdinal;
|
||||||
pub use point::ScalePoint;
|
pub use point::ScalePoint;
|
||||||
pub(crate) use sealed::Sealed;
|
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 bar;
|
||||||
mod line;
|
mod line;
|
||||||
mod pie;
|
mod pie;
|
||||||
|
mod stack;
|
||||||
|
|
||||||
pub use arc::{Arc, ArcData};
|
pub use arc::{Arc, ArcData};
|
||||||
pub use area::Area;
|
pub use area::Area;
|
||||||
pub use bar::Bar;
|
pub use bar::Bar;
|
||||||
pub use line::Line;
|
pub use line::Line;
|
||||||
pub use pie::Pie;
|
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::{
|
use crate::plot::{
|
||||||
label::{PlotLabel, Text, TEXT_GAP, TEXT_HEIGHT},
|
label::{PlotLabel, TEXT_GAP, TEXT_HEIGHT, Text},
|
||||||
origin_point,
|
origin_point,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -10,7 +10,7 @@ pub struct Bar<T> {
|
||||||
data: Vec<T>,
|
data: Vec<T>,
|
||||||
x: Box<dyn Fn(&T) -> Option<f32>>,
|
x: Box<dyn Fn(&T) -> Option<f32>>,
|
||||||
band_width: f32,
|
band_width: f32,
|
||||||
y0: f32,
|
y0: Box<dyn Fn(&T) -> f32>,
|
||||||
y1: Box<dyn Fn(&T) -> Option<f32>>,
|
y1: Box<dyn Fn(&T) -> Option<f32>>,
|
||||||
fill: Box<dyn Fn(&T) -> Hsla>,
|
fill: Box<dyn Fn(&T) -> Hsla>,
|
||||||
label: Option<Box<dyn Fn(&T, Point<Pixels>) -> Vec<Text>>>,
|
label: Option<Box<dyn Fn(&T, Point<Pixels>) -> Vec<Text>>>,
|
||||||
|
|
@ -22,7 +22,7 @@ impl<T> Default for Bar<T> {
|
||||||
data: Vec::new(),
|
data: Vec::new(),
|
||||||
x: Box::new(|_| None),
|
x: Box::new(|_| None),
|
||||||
band_width: 0.,
|
band_width: 0.,
|
||||||
y0: 0.,
|
y0: Box::new(|_| 0.),
|
||||||
y1: Box::new(|_| None),
|
y1: Box::new(|_| None),
|
||||||
fill: Box::new(|_| gpui::black()),
|
fill: Box::new(|_| gpui::black()),
|
||||||
label: None,
|
label: None,
|
||||||
|
|
@ -60,8 +60,11 @@ impl<T> Bar<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the y0 of the Bar.
|
/// Set the y0 of the Bar.
|
||||||
pub fn y0(mut self, y0: f32) -> Self {
|
pub fn y0<F>(mut self, y: F) -> Self
|
||||||
self.y0 = y0;
|
where
|
||||||
|
F: Fn(&T) -> f32 + 'static,
|
||||||
|
{
|
||||||
|
self.y0 = Box::new(y);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,18 +104,19 @@ impl<T> Bar<T> {
|
||||||
for v in &self.data {
|
for v in &self.data {
|
||||||
let x_tick = (self.x)(v);
|
let x_tick = (self.x)(v);
|
||||||
let y_tick = (self.y1)(v);
|
let y_tick = (self.y1)(v);
|
||||||
|
let y0 = (self.y0)(v);
|
||||||
|
|
||||||
if let (Some(x_tick), Some(y_tick)) = (x_tick, y_tick) {
|
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 {
|
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),
|
origin_point(px(x_tick + self.band_width), px(y_tick), origin),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
origin_point(px(x_tick), px(y_tick), origin),
|
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