diff --git a/crates/story/src/chart_story/chart_story.rs b/crates/story/src/chart_story/chart_story.rs index ec719841..cde11e1b 100644 --- a/crates/story/src/chart_story/chart_story.rs +++ b/crates/story/src/chart_story/chart_story.rs @@ -5,7 +5,7 @@ use gpui::{ }; use gpui_component::{ ActiveTheme, StyledExt, - chart::{AreaChart, BarChart, LineChart, PieChart}, + chart::{AreaChart, BarChart, CandlestickChart, LineChart, PieChart}, divider::Divider, dock::PanelControl, h_flex, v_flex, @@ -36,10 +36,20 @@ pub struct DailyDevice { pub watch: f64, } +#[derive(Clone, Deserialize)] +pub struct StockPrice { + pub date: SharedString, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, +} + pub struct ChartStory { focus_handle: FocusHandle, daily_devices: Vec, monthly_devices: Vec, + stock_prices: Vec, } impl ChartStory { @@ -52,10 +62,14 @@ impl ChartStory { "../fixtures/monthly-devices.json" )) .unwrap(); + let stock_prices = + serde_json::from_str::>(include_str!("../fixtures/stock-prices.json")) + .unwrap(); Self { daily_devices, monthly_devices, + stock_prices, focus_handle: cx.focus_handle(), } } @@ -326,5 +340,58 @@ impl Render for ChartStory { cx, )), ) + .child(Divider::horizontal()) + .child( + h_flex() + .gap_x_4() + .h(px(400.)) + .child(chart_container( + "Candlestick Chart", + CandlestickChart::new(self.stock_prices.clone()) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close), + false, + cx, + )) + .child(chart_container( + "Candlestick Chart - Narrow", + CandlestickChart::new(self.stock_prices.clone()) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close) + .body_width_ratio(0.5), + false, + cx, + )) + .child(chart_container( + "Candlestick Chart - Wide", + CandlestickChart::new(self.stock_prices.clone()) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close) + .body_width_ratio(1.0), + false, + cx, + )) + .child(chart_container( + "Candlestick Chart - Tick Margin", + CandlestickChart::new(self.stock_prices.clone()) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close) + .tick_margin(2), + false, + cx, + )), + ) } } diff --git a/crates/story/src/fixtures/stock-prices.json b/crates/story/src/fixtures/stock-prices.json new file mode 100644 index 00000000..4ccf4579 --- /dev/null +++ b/crates/story/src/fixtures/stock-prices.json @@ -0,0 +1,44 @@ +[ + { + "date": "Jan", + "open": 100.0, + "high": 112.0, + "low": 95.0, + "close": 110.0 + }, + { + "date": "Feb", + "open": 110.0, + "high": 112.0, + "low": 108.0, + "close": 111.0 + }, + { + "date": "Mar", + "open": 111.0, + "high": 118.0, + "low": 110.0, + "close": 116.0 + }, + { + "date": "Apr", + "open": 116.0, + "high": 120.0, + "low": 108.0, + "close": 110.0 + }, + { + "date": "May", + "open": 110.0, + "high": 118.0, + "low": 105.0, + "close": 115.0 + }, + { + "date": "Jun", + "open": 115.0, + "high": 125.0, + "low": 113.0, + "close": 123.0 + } +] diff --git a/crates/ui/src/chart/candlestick_chart.rs b/crates/ui/src/chart/candlestick_chart.rs new file mode 100644 index 00000000..7e362016 --- /dev/null +++ b/crates/ui/src/chart/candlestick_chart.rs @@ -0,0 +1,223 @@ +use std::rc::Rc; + +use gpui::{App, Bounds, Hsla, PathBuilder, Pixels, SharedString, TextAlign, Window, fill, px}; +use gpui_component_macros::IntoPlot; +use num_traits::{Num, ToPrimitive}; + +use crate::{ + ActiveTheme, PixelsExt, + plot::{ + AXIS_GAP, AxisText, Grid, Plot, PlotAxis, origin_point, + scale::{Scale, ScaleBand, ScaleLinear, Sealed}, + }, +}; + +#[derive(IntoPlot)] +pub struct CandlestickChart +where + T: 'static, + X: PartialEq + Into + 'static, + Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + data: Vec, + x: Option X>>, + open: Option Y>>, + high: Option Y>>, + low: Option Y>>, + close: Option Y>>, + tick_margin: usize, + body_width_ratio: f32, +} + +impl CandlestickChart +where + X: PartialEq + Into + 'static, + Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + pub fn new(data: I) -> Self + where + I: IntoIterator, + { + Self { + data: data.into_iter().collect(), + x: None, + open: None, + high: None, + low: None, + close: None, + tick_margin: 1, + body_width_ratio: 0.8, + } + } + + pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self { + self.x = Some(Rc::new(x)); + self + } + + pub fn open(mut self, open: impl Fn(&T) -> Y + 'static) -> Self { + self.open = Some(Rc::new(open)); + self + } + + pub fn high(mut self, high: impl Fn(&T) -> Y + 'static) -> Self { + self.high = Some(Rc::new(high)); + self + } + + pub fn low(mut self, low: impl Fn(&T) -> Y + 'static) -> Self { + self.low = Some(Rc::new(low)); + self + } + + pub fn close(mut self, close: impl Fn(&T) -> Y + 'static) -> Self { + self.close = Some(Rc::new(close)); + self + } + + pub fn tick_margin(mut self, tick_margin: usize) -> Self { + self.tick_margin = tick_margin; + self + } + + pub fn body_width_ratio(mut self, ratio: f32) -> Self { + self.body_width_ratio = ratio; + self + } +} + +impl Plot for CandlestickChart +where + X: PartialEq + Into + 'static, + Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { + let (Some(x_fn), Some(open_fn), Some(high_fn), Some(low_fn), Some(close_fn)) = ( + self.x.as_ref(), + self.open.as_ref(), + self.high.as_ref(), + self.low.as_ref(), + self.close.as_ref(), + ) else { + return; + }; + + let width = bounds.size.width.as_f32(); + let height = bounds.size.height.as_f32() - AXIS_GAP; + + // X scale + let x = ScaleBand::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]) + .padding_inner(0.4) + .padding_outer(0.2); + let band_width = x.band_width(); + + // Y scale + let all_values: Vec = self + .data + .iter() + .flat_map(|d| vec![high_fn(d), low_fn(d), open_fn(d), close_fn(d)]) + .collect(); + let y = ScaleLinear::new(all_values, vec![height, 10.]); + + // Draw X axis + let x_label = self.data.iter().enumerate().filter_map(|(i, d)| { + if (i + 1) % self.tick_margin == 0 { + x.tick(&x_fn(d)).map(|x_tick| { + AxisText::new( + x_fn(d).into(), + x_tick + band_width / 2., + cx.theme().muted_foreground, + ) + .align(TextAlign::Center) + }) + } else { + None + } + }); + + PlotAxis::new() + .x(height) + .x_label(x_label) + .stroke(cx.theme().border) + .paint(&bounds, window, cx); + + // Draw grid + 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); + + // Draw candlesticks + let origin = bounds.origin; + let x_fn = x_fn.clone(); + let open_fn = open_fn.clone(); + let high_fn = high_fn.clone(); + let low_fn = low_fn.clone(); + let close_fn = close_fn.clone(); + + for d in &self.data { + let x_tick = x.tick(&x_fn(d)); + let Some(x_tick) = x_tick else { + continue; + }; + + // Get OHLC values for the current data point + let open = open_fn(d); + let high = high_fn(d); + let low = low_fn(d); + let close = close_fn(d); + + // Convert values to pixel coordinates + let open_y = y.tick(&open); + let high_y = y.tick(&high); + let low_y = y.tick(&low); + let close_y = y.tick(&close); + + let (Some(open_y), Some(high_y), Some(low_y), Some(close_y)) = + (open_y, high_y, low_y, close_y) + else { + continue; + }; + + // Determine if bullish (close > open) or bearish (close < open) + let is_bullish = close > open; + let color: Hsla = if is_bullish { + cx.theme().bullish + } else { + cx.theme().bearish + }; + + // Calculate candlestick body dimensions + let center_x = x_tick + band_width / 2.; + let body_width = band_width * self.body_width_ratio; + let body_left = center_x - body_width / 2.; + let body_right = center_x + body_width / 2.; + + // Draw wick (high to low line) + let mut wick_builder = PathBuilder::stroke(px(1.)); + wick_builder.move_to(origin_point(px(center_x), px(high_y), origin)); + wick_builder.line_to(origin_point(px(center_x), px(low_y), origin)); + + if let Ok(path) = wick_builder.build() { + window.paint_path(path, color); + } + + // Draw body (open to close rectangle) + // For bullish: top is close, bottom is open + // For bearish: top is open, bottom is close + let (top, bottom) = if is_bullish { + (close_y, open_y) + } else { + (open_y, close_y) + }; + + let body_bounds = Bounds::from_corners( + origin_point(px(body_left), px(top), origin), + origin_point(px(body_right), px(bottom), origin), + ); + + window.paint_quad(fill(body_bounds, color)); + } + } +} diff --git a/crates/ui/src/chart/mod.rs b/crates/ui/src/chart/mod.rs index c96c69d7..bcff4c21 100644 --- a/crates/ui/src/chart/mod.rs +++ b/crates/ui/src/chart/mod.rs @@ -1,9 +1,11 @@ mod area_chart; mod bar_chart; +mod candlestick_chart; mod line_chart; mod pie_chart; pub use area_chart::AreaChart; pub use bar_chart::BarChart; +pub use candlestick_chart::CandlestickChart; pub use line_chart::LineChart; pub use pie_chart::PieChart; diff --git a/crates/ui/src/theme/default-theme.json b/crates/ui/src/theme/default-theme.json index 670cf3b4..1943f93b 100644 --- a/crates/ui/src/theme/default-theme.json +++ b/crates/ui/src/theme/default-theme.json @@ -77,6 +77,8 @@ "success.active.background": "#16a34a", "success.foreground": "#f9fafb", "success.hover.background": "#22c55ee6", + "bullish.background": "#22c55e", + "bearish.background": "#ef4444", "switch.background": "#d4d4d4", "tab.background": "#00000000", "tab.active.background": "#ffffff", @@ -278,6 +280,8 @@ "success.active.background": "#104224", "success.foreground": "#f0fdf4", "success.hover.background": "#165b32", + "bullish.background": "#22c55e", + "bearish.background": "#ef4444", "switch.background": "#404040", "tab.background": "#00000000", "tab.active.background": "#0a0a0a", diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index 5b3f638f..ae23417b 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -281,6 +281,12 @@ pub struct ThemeConfigColors { /// Success active background color. #[serde(rename = "success.active.background")] pub success_active: Option, + /// Bullish color for candlestick charts (upward price movement). + #[serde(rename = "bullish.background")] + pub bullish: Option, + /// Bearish color for candlestick charts (downward price movement). + #[serde(rename = "bearish.background")] + pub bearish: Option, /// Switch background color. #[serde(rename = "switch.background")] pub switch: Option, @@ -508,6 +514,8 @@ impl ThemeColor { success_active, fallback = self.success.darken(active_darken) ); + apply_color!(bullish, fallback = self.green); + apply_color!(bearish, fallback = self.red); apply_color!(info, fallback = self.cyan); apply_color!(info_foreground, fallback = self.primary_foreground); apply_color!( diff --git a/crates/ui/src/theme/theme_color.rs b/crates/ui/src/theme/theme_color.rs index c148c494..a575b105 100644 --- a/crates/ui/src/theme/theme_color.rs +++ b/crates/ui/src/theme/theme_color.rs @@ -147,6 +147,10 @@ pub struct ThemeColor { pub success_hover: Hsla, /// Success active background color. pub success_active: Hsla, + /// Bullish color for candlestick charts (upward price movement). + pub bullish: Hsla, + /// Bearish color for candlestick charts (downward price movement). + pub bearish: Hsla, /// Switch background color. pub switch: Hsla, /// Switch thumb background color. diff --git a/docs/docs/components/chart.md b/docs/docs/components/chart.md index 9020871d..0ff550bd 100644 --- a/docs/docs/components/chart.md +++ b/docs/docs/components/chart.md @@ -1,16 +1,16 @@ --- title: Chart -description: Beautiful charts and graphs for data visualization including line, bar, area, and pie charts. +description: Beautiful charts and graphs for data visualization including line, bar, area, pie, and candlestick charts. --- # Chart -A comprehensive charting library providing Line, Bar, Area, and Pie charts for data visualization. The charts feature smooth animations, customizable styling, tooltips, legends, and automatic theming that adapts to your application's theme. +A comprehensive charting library providing Line, Bar, Area, Pie, and Candlestick charts for data visualization. The charts feature smooth animations, customizable styling, tooltips, legends, and automatic theming that adapts to your application's theme. ## Import ```rust -use gpui_component::chart::{LineChart, BarChart, AreaChart, PieChart}; +use gpui_component::chart::{LineChart, BarChart, AreaChart, PieChart, CandlestickChart}; ``` ## Chart Types @@ -208,6 +208,65 @@ PieChart::new(data) .pad_angle(4. / 100.) // 4% padding ``` +### CandlestickChart + +A candlestick chart displays financial data using OHLC (Open, High, Low, Close) values, perfect for visualizing stock prices and market trends. + +#### Basic Candlestick Chart + +```rust +#[derive(Clone)] +struct StockPrice { + pub date: String, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, +} + +let data = vec![ + StockPrice { date: "Jan".to_string(), open: 100.0, high: 110.0, low: 95.0, close: 105.0 }, + StockPrice { date: "Feb".to_string(), open: 105.0, high: 115.0, low: 100.0, close: 112.0 }, + StockPrice { date: "Mar".to_string(), open: 112.0, high: 120.0, low: 108.0, close: 115.0 }, +]; + +CandlestickChart::new(data) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close) +``` + +#### Candlestick Chart Customization + +```rust +// Adjust body width ratio (default: 0.6) +CandlestickChart::new(data) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close) + .body_width_ratio(0.4) // Narrower bodies + +// Custom tick spacing +CandlestickChart::new(data) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close) + .tick_margin(2) // Show every 2nd tick +``` + +#### Candlestick Chart Colors + +The candlestick chart automatically uses theme colors: + +- **Bullish** (close > open): `bullish` color (green) +- **Bearish** (close < open): `bearish` color (red) + ## Data Structures ### Example Data Types @@ -318,6 +377,7 @@ let chart = LineChart::new(data) - [BarChart] - [AreaChart] - [PieChart] +- [CandlestickChart] ## Examples @@ -443,13 +503,36 @@ struct StockData { volume: u64, } -fn stock_chart(data: Vec, cx: &mut Context) -> impl IntoElement { +#[derive(Clone)] +struct StockOHLC { + date: String, + open: f64, + high: f64, + low: f64, + close: f64, +} + +fn stock_chart(ohlc_data: Vec, price_data: Vec, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( chart_container( - "Stock Price", - LineChart::new(data.clone()) + "Stock Price - Candlestick", + CandlestickChart::new(ohlc_data.clone()) + .x(|d| d.date.clone()) + .open(|d| d.open) + .high(|d| d.high) + .low(|d| d.low) + .close(|d| d.close) + .tick_margin(3), + false, + cx, + ) + ) + .child( + chart_container( + "Stock Price - Line", + LineChart::new(price_data.clone()) .x(|d| d.date.clone()) .y(|d| d.price) .stroke(cx.theme().chart_1) @@ -462,7 +545,7 @@ fn stock_chart(data: Vec, cx: &mut Context) -> impl IntoElement .child( chart_container( "Trading Volume", - BarChart::new(data) + BarChart::new(price_data) .x(|d| d.date.clone()) .y(|d| d.volume as f64) .fill(|d| { @@ -627,3 +710,4 @@ impl LiveChart { [BarChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.BarChart.html [AreaChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.AreaChart.html [PieChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.PieChart.html +[CandlestickChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.CandlestickChart.html diff --git a/docs/docs/components/index.md b/docs/docs/components/index.md index f4dbedc7..dc963ca3 100644 --- a/docs/docs/components/index.md +++ b/docs/docs/components/index.md @@ -56,7 +56,7 @@ collapsed: false ### Advanced Components - [Calendar](calendar) - Calendar display and navigation -- [Chart](chart) - Data visualization charts (Line, Bar, Area, Pie) +- [Chart](chart) - Data visualization charts (Line, Bar, Area, Pie, Candlestick) - [List](list) - List display with items - [Menu](menu) - Menu and context menu and dropdown menu. - [Settings](settings) - Settings UI