diff --git a/crates/story/src/chart_story.rs b/crates/story/src/chart_story.rs index a708b539..3a501d07 100644 --- a/crates/story/src/chart_story.rs +++ b/crates/story/src/chart_story.rs @@ -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, - monthly_devices: Vec, -} - -impl ChartStory { - fn new(_: &mut Window, cx: &mut Context) -> Self { - let daily_devices = - serde_json::from_str::>(include_str!("fixtures/daily-devices.json")) - .unwrap(); - let monthly_devices = serde_json::from_str::>(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 { - 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 { - Self::view(window, cx) - } - - fn zoomable() -> Option { - 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, -) -> 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) -> 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; diff --git a/crates/story/src/chart_story/chart_story.rs b/crates/story/src/chart_story/chart_story.rs new file mode 100644 index 00000000..ec719841 --- /dev/null +++ b/crates/story/src/chart_story/chart_story.rs @@ -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, + monthly_devices: Vec, +} + +impl ChartStory { + fn new(_: &mut Window, cx: &mut Context) -> Self { + let daily_devices = serde_json::from_str::>(include_str!( + "../fixtures/daily-devices.json" + )) + .unwrap(); + let monthly_devices = serde_json::from_str::>(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 { + 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 { + Self::view(window, cx) + } + + fn zoomable() -> Option { + 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, +) -> 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) -> 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, + )), + ) + } +} diff --git a/crates/story/src/chart_story/stacked_bar_chart.rs b/crates/story/src/chart_story/stacked_bar_chart.rs new file mode 100644 index 00000000..b005cecb --- /dev/null +++ b/crates/story/src/chart_story/stacked_bar_chart.rs @@ -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, + series: Vec>, +} + +impl StackedBarChart { + pub fn new(data: Vec) -> 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, 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); + } + } +} diff --git a/crates/story/src/fixtures/daily-devices.json b/crates/story/src/fixtures/daily-devices.json index 594be51f..558f7a62 100644 --- a/crates/story/src/fixtures/daily-devices.json +++ b/crates/story/src/fixtures/daily-devices.json @@ -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 } -] +] \ No newline at end of file diff --git a/crates/ui/src/chart/bar_chart.rs b/crates/ui/src/chart/bar_chart.rs index 31fb79d7..8f53499a 100644 --- a/crates/ui/src/chart/bar_chart.rs +++ b/crates/ui/src/chart/bar_chart.rs @@ -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)); diff --git a/crates/ui/src/plot/scale.rs b/crates/ui/src/plot/scale.rs index 9befebd3..683d3c74 100644 --- a/crates/ui/src/plot/scale.rs +++ b/crates/ui/src/plot/scale.rs @@ -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; diff --git a/crates/ui/src/plot/scale/ordinal.rs b/crates/ui/src/plot/scale/ordinal.rs new file mode 100644 index 00000000..d02a524e --- /dev/null +++ b/crates/ui/src/plot/scale/ordinal.rs @@ -0,0 +1,104 @@ +// @reference: https://d3js.org/d3-scale/ordinal + +#[derive(Clone)] +pub struct ScaleOrdinal { + domain: Vec, + range: Vec, + unknown: Option, +} + +impl Default for ScaleOrdinal { + fn default() -> Self { + Self { + domain: Vec::new(), + range: Vec::new(), + unknown: None, + } + } +} + +impl ScaleOrdinal { + pub fn new(domain: Vec, range: Vec) -> Self { + Self { + domain, + range, + unknown: None, + } + } + + /// Set the domain to the specified array of values. + pub fn domain(mut self, domain: Vec) -> 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) -> 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 ScaleOrdinal +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 { + 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")); + } +} diff --git a/crates/ui/src/plot/shape.rs b/crates/ui/src/plot/shape.rs index 1792b4f9..0da8fbbc 100644 --- a/crates/ui/src/plot/shape.rs +++ b/crates/ui/src/plot/shape.rs @@ -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}; diff --git a/crates/ui/src/plot/shape/bar.rs b/crates/ui/src/plot/shape/bar.rs index 3cfd62fa..a7238eb4 100644 --- a/crates/ui/src/plot/shape/bar.rs +++ b/crates/ui/src/plot/shape/bar.rs @@ -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 { data: Vec, x: Box Option>, band_width: f32, - y0: f32, + y0: Box f32>, y1: Box Option>, fill: Box Hsla>, label: Option) -> Vec>>, @@ -22,7 +22,7 @@ impl Default for Bar { 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 Bar { } /// Set the y0 of the Bar. - pub fn y0(mut self, y0: f32) -> Self { - self.y0 = y0; + pub fn y0(mut self, y: F) -> Self + where + F: Fn(&T) -> f32 + 'static, + { + self.y0 = Box::new(y); self } @@ -101,18 +104,19 @@ impl Bar { 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), ) }; diff --git a/crates/ui/src/plot/shape/stack.rs b/crates/ui/src/plot/shape/stack.rs new file mode 100644 index 00000000..8c026177 --- /dev/null +++ b/crates/ui/src/plot/shape/stack.rs @@ -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 { + /// 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 { + /// The key for this series + pub key: String, + /// The index of this series + pub index: usize, + /// The points in this series + pub points: Vec>, +} + +#[allow(clippy::type_complexity)] +pub struct Stack { + data: Vec, + keys: Vec, + value: Box Option>, +} + +impl Default for Stack { + fn default() -> Self { + Self { + data: Vec::new(), + keys: Vec::new(), + value: Box::new(|_, _| None), + } + } +} + +impl Stack { + pub fn new() -> Self { + Self::default() + } + + /// Set the data to be stacked + pub fn data(mut self, data: I) -> Self + where + I: IntoIterator, + { + self.data = data.into_iter().collect(); + self + } + + /// Set the keys (series) for stacking + pub fn keys(mut self, keys: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.keys = keys.into_iter().map(|s| s.into()).collect(); + self + } + + /// Set the value accessor function + pub fn value(mut self, value: F) -> Self + where + F: Fn(&T, &str) -> Option + 'static, + { + self.value = Box::new(value); + self + } + + /// Compute the stacked series + pub fn series(&self) -> Vec> { + 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::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 = (0..m).collect(); + + // Initialize stacks with zeros + let mut stacks: Vec> = 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); + } +} diff --git a/docs/docs/components/plot.md b/docs/docs/components/plot.md new file mode 100644 index 00000000..413dff4d --- /dev/null +++ b/docs/docs/components/plot.md @@ -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> +``` + +## 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, + series: Vec>, +} + +impl StackedBarChart { + pub fn new(data: Vec) -> 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, 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); + } +} +```