From 9b3b2802ccf04f918d04337e12bac1db1a879414 Mon Sep 17 00:00:00 2001 From: amiyzku Date: Fri, 5 Dec 2025 19:24:25 +0900 Subject: [PATCH] chart: Add candlestick chart (#1749) ## Description Add `CandlestickChart` component for visualizing financial OHLC data with candlestick patterns. - Introduced a new CandlestickChart for displaying OHLC (Open, High, Low, Close) values. - Updated ChartStory to include stock price data and render multiple candlestick chart variations. - Enhanced theme with bullish and bearish colors for candlestick representation. - Updated documentation to include CandlestickChart examples and usage. This addition enhances the charting library by providing a crucial tool for visualizing stock market trends. ## Screenshot image ## How to Test ```bash cargo run -p gpui-component-story ``` ## Checklist - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) document and followed the guidelines. - [x] Reviewed the changes in this PR and confirmed AI generated code (If any) is accurate. - [x] Passed `cargo run` for story tests related to the changes. - [ ] Tested macOS, Windows and Linux platforms performance (if the change is platform-specific) --- crates/story/src/chart_story/chart_story.rs | 69 +++++- crates/story/src/fixtures/stock-prices.json | 44 ++++ crates/ui/src/chart/candlestick_chart.rs | 223 ++++++++++++++++++++ crates/ui/src/chart/mod.rs | 2 + crates/ui/src/theme/default-theme.json | 4 + crates/ui/src/theme/schema.rs | 8 + crates/ui/src/theme/theme_color.rs | 4 + docs/docs/components/chart.md | 98 ++++++++- docs/docs/components/index.md | 2 +- 9 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 crates/story/src/fixtures/stock-prices.json create mode 100644 crates/ui/src/chart/candlestick_chart.rs 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