Skip to content

Custom series

A custom series combines an Indicator that computes values with a Primitive that draws and hit-tests them. The chart keeps the study’s legend, crosshair, last-value tag and automatic range; your primitive replaces its plot geometry. Start with primitives for lifecycle, passes and axes.

This scene adapts the renderer from the native primitives example. Each value is high minus low, the bar’s full range, in price units. For example, a high of 103 and a low of 99 give 4 regardless of open and close. This is not true range, which also considers the previous close.

Bar ranges drawn as lollipopsOpen full size

Zoom in and hover a round head in the lower pane to get the pointing-hand cursor; click it to select the study.

The interactive example needs JavaScript and WebAssembly. You can still use the chart setup alongside it.

custom_series.rs
/// Draw each bar's high minus low as a lollipop in a separate pane.
pub fn custom_series() -> egui_charts::Chart {
use eframe::egui::{Color32, CursorIcon, Painter, Pos2, Stroke};
use egui_charts::model::Bar;
use egui_charts::studies::{CustomIndicator, IndicatorValue};
use egui_charts::widget::{Primitive, PrimitivePass, PrimitiveView};
/// Draws the values of its study as stems from zero, each with a round head.
struct Lollipops;
impl Lollipops {
fn head(view: &PrimitiveView<'_>, index: usize) -> Option<(Pos2, f32, Color32)> {
let study = view.study?;
let value = study.plot_value(0, index)?;
let radius = (view.mapping.bar_spacing * 0.3).clamp(1.5, 4.0);
let color = study.colors().first().copied().unwrap_or(Color32::GRAY);
let centre = Pos2::new(
view.index_to_x(index as f64),
view.mapping.price_to_y(value),
);
Some((centre, radius, color))
}
}
impl Primitive for Lollipops {
fn draw(&self, painter: &Painter, view: &PrimitiveView<'_>, pass: PrimitivePass) {
if pass != PrimitivePass::Series {
return;
}
let zero = view.mapping.price_to_y(0.0);
for index in view.visible.clone() {
if let Some((centre, radius, color)) = Self::head(view, index) {
painter
.line_segment([Pos2::new(centre.x, zero), centre], Stroke::new(1.0, color));
painter.circle_filled(centre, radius, color);
}
}
}
fn autoscale(&self, _bars: &[Bar], _visible: std::ops::Range<usize>) -> Option<(f64, f64)> {
// The study's values set the top of the pane; the stems start at zero.
Some((0.0, 0.0))
}
fn hit_test(&self, pos: Pos2, view: &PrimitiveView<'_>) -> Option<CursorIcon> {
let index = view.mapping.x_to_idx_f64(pos.x).round();
if index < 0.0 || !view.visible.contains(&(index as usize)) {
return None;
}
let (centre, radius, _) = Self::head(view, index as usize)?;
(centre.distance(pos) <= radius + 3.0).then_some(CursorIcon::PointingHand)
}
}
let mut chart = sample_chart();
let range = CustomIndicator::new("Bar Range", |bars| {
bars.iter()
.map(|bar| IndicatorValue::Single(bar.high - bar.low))
.collect()
})
.with_overlay(false)
.with_color(Color32::from_rgb(255, 152, 0));
chart.add_custom_series(Box::new(range), Lollipops);
chart
}
/// Shared fixed fifteen-minute history for the two primitive scenes.
fn sample_chart() -> egui_charts::Chart {
use egui_charts::{Chart, model::BarData};
let mut chart = Chart::new(BarData::from_bars((0..480).map(sample_bar).collect()));
chart.set_symbol("SAMPLE");
chart.set_visible_bars(80);
chart.config_mut().show_volume = false;
chart
}
/// Sample bar `i` of fifteen minutes: a deterministic wave, not market data.
fn sample_bar(i: usize) -> egui_charts::model::Bar {
use chrono::{TimeZone, Utc};
use egui_charts::model::Bar;
let t = i as f64;
let open = 100.0 + 8.0 * (t / 50.0).sin() + 2.0 * (t / 9.0).sin();
let close = open + 1.2 * (t * 1.3).sin();
let spread = 0.4 + 0.6 * (t / 6.0).sin().abs();
let time = Utc
.timestamp_opt(1_700_006_400 + 900 * i as i64, 0)
.unwrap();
Bar::new(
time,
open,
open.max(close) + spread,
open.min(close) - spread,
close,
1000.0 + 500.0 * (t / 3.0).cos(),
)
}

CustomIndicator::new is a closure-based implementation available with widget, even without the built-in studies feature. Return one IndicatorValue per source bar, in the same order. For missing or warm-up outputs use IndicatorValue::None; if your source includes whitespace, handle it explicitly instead of turning its non-finite prices into a plotted zero.

with_overlay(false) requests a separate pane. Pass the study and renderer to Chart::add_custom_series and keep the returned SeriesId. There is no new ChartType variant to register. For a richer study definition, implement Indicator as described in the studies guide.

PrimitiveView::study is Some for a study attachment. The renderer reads plot_value(0, index) and colors() from it. It draws only view.visible in the Series pass, converting indices and values with the current pane mapping. view.bars contains the main source bars, while the plotted value comes from the study.

The study’s values determine the pane’s upper range. The primitive’s autoscale adds (0.0, 0.0) because each stem starts at zero. Without it, fitting only the positive heads could clip the baseline. Both drawing and hit testing call the same head function; the hit radius adds three screen points for easier aiming. It does not claim the empty space between stems.

Custom series are studies, so they follow the main bars supplied through set_data(SeriesId::MAIN, bars) and update(SeriesId::MAIN, bar). The host supplies historical and streaming data. The renderer’s data_changed hook receives those source bars; it does not receive a separate custom payload through set_data on the study ID.

Use set_series_visible(id, visible) to hide or show the study and its renderer, and move_series_to_pane to move both. remove_series(id) removes the study and detaches its renderer. Undoing that removal does not reconstruct the host renderer.

A renderer hit is reported as PointerTarget::Element containing ChartElementId::Indicator(id), so clicking selects the study rather than a separate primitive ID. Read ChartEvent values after show for host interactions. The series guide covers the shared identity and lifecycle APIs; the primitive guide covers additional drawing passes, axes and merged rendering.