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.
Compute values, then draw them
Section titled “Compute values, then draw them”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.
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.
/// 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.
Map the study, not candle closes
Section titled “Map the study, not candle closes”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.
Identity, updates and removal
Section titled “Identity, updates and removal”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.