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

<img width="1335" height="423" alt="image"
src="https://github.com/user-attachments/assets/5df7078e-a469-4262-b645-6434d08b7ccf"
/>

## 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)
This commit is contained in:
amiyzku 2025-12-05 19:24:25 +09:00 committed by Jason Lee
parent bc1092a6e1
commit 9b3b2802cc
9 changed files with 445 additions and 9 deletions

View file

@ -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<DailyDevice>,
monthly_devices: Vec<MonthlyDevice>,
stock_prices: Vec<StockPrice>,
}
impl ChartStory {
@ -52,10 +62,14 @@ impl ChartStory {
"../fixtures/monthly-devices.json"
))
.unwrap();
let stock_prices =
serde_json::from_str::<Vec<StockPrice>>(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,
)),
)
}
}

View file

@ -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
}
]

View file

@ -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<T, X, Y>
where
T: 'static,
X: PartialEq + Into<SharedString> + 'static,
Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
{
data: Vec<T>,
x: Option<Rc<dyn Fn(&T) -> X>>,
open: Option<Rc<dyn Fn(&T) -> Y>>,
high: Option<Rc<dyn Fn(&T) -> Y>>,
low: Option<Rc<dyn Fn(&T) -> Y>>,
close: Option<Rc<dyn Fn(&T) -> Y>>,
tick_margin: usize,
body_width_ratio: f32,
}
impl<T, X, Y> CandlestickChart<T, X, Y>
where
X: PartialEq + Into<SharedString> + 'static,
Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
{
pub fn new<I>(data: I) -> Self
where
I: IntoIterator<Item = T>,
{
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<T, X, Y> Plot for CandlestickChart<T, X, Y>
where
X: PartialEq + Into<SharedString> + 'static,
Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
{
fn paint(&mut self, bounds: Bounds<Pixels>, 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<Y> = 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));
}
}
}

View file

@ -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;

View file

@ -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",

View file

@ -281,6 +281,12 @@ pub struct ThemeConfigColors {
/// Success active background color.
#[serde(rename = "success.active.background")]
pub success_active: Option<SharedString>,
/// Bullish color for candlestick charts (upward price movement).
#[serde(rename = "bullish.background")]
pub bullish: Option<SharedString>,
/// Bearish color for candlestick charts (downward price movement).
#[serde(rename = "bearish.background")]
pub bearish: Option<SharedString>,
/// Switch background color.
#[serde(rename = "switch.background")]
pub switch: Option<SharedString>,
@ -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!(

View file

@ -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.

View file

@ -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<StockData>, cx: &mut Context<Self>) -> impl IntoElement {
#[derive(Clone)]
struct StockOHLC {
date: String,
open: f64,
high: f64,
low: f64,
close: f64,
}
fn stock_chart(ohlc_data: Vec<StockOHLC>, price_data: Vec<StockData>, cx: &mut Context<Self>) -> 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<StockData>, cx: &mut Context<Self>) -> 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

View file

@ -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