Skip to content

Primitives and rendering extensions

Use a Primitive to draw host geometry on an existing series. Use a custom series when a study supplies values and your renderer replaces its normal plots. Both need only the widget feature; neither requires a change to the crate’s renderer dispatch. For another horizontal domain, see custom horizontal scales.

The scene adapts the session highlight from the native primitives example. It caches session boundaries when the source bars change, shades them in the Below pass, and labels each open on the time axis. This sample uses fixed 14:30 to 21:00 UTC hours; a production host supplies the relevant exchange calendar, holidays and daylight-saving rules.

Session shading from a primitiveOpen full size

Pan across session opens and zoom in: the shading stays behind the candles and the open tags follow the time axis.

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

primitives.rs
/// Shade regular sessions and label their opens on the shared time axis.
pub fn primitives() -> egui_charts::Chart {
use chrono::{DateTime, Duration, Utc};
use eframe::egui::{Color32, Painter, Rect};
use egui_charts::model::Bar;
use egui_charts::widget::{Primitive, PrimitivePass, PrimitiveView, SeriesId, TimeAxisLabel};
/// Shades the regular session of every day behind the bars and labels its open on the time axis.
struct SessionHighlight {
/// Open and close after midnight UTC.
open: Duration,
close: Duration,
/// Open and close of each day the bars cover.
sessions: Vec<(DateTime<Utc>, DateTime<Utc>)>,
}
impl Primitive for SessionHighlight {
fn data_changed(&mut self, bars: &[Bar]) {
self.sessions.clear();
let (Some(first), Some(last)) = (bars.first(), bars.last()) else {
return;
};
let mut day = first.time.date_naive();
while day <= last.time.date_naive() {
let midnight = day.and_hms_opt(0, 0, 0).unwrap().and_utc();
self.sessions
.push((midnight + self.open, midnight + self.close));
day = day.succ_opt().unwrap();
}
}
fn draw(&self, painter: &Painter, view: &PrimitiveView<'_>, pass: PrimitivePass) {
if pass != PrimitivePass::Below {
return;
}
let plot = view.mapping.rect;
// Bars are centred on their open times, so a session starts half a bar before its first.
let half = view.mapping.bar_spacing / 2.0;
for (open, close) in &self.sessions {
if let (Some(left), Some(right)) = (view.time_to_x(*open), view.time_to_x(*close)) {
painter.rect_filled(
Rect::from_x_y_ranges(left - half..=right - half, plot.y_range()),
0.0,
Color32::from_rgba_unmultiplied(41, 98, 255, 24),
);
}
}
}
fn time_axis_labels(&self, _view: &PrimitiveView<'_>) -> Vec<TimeAxisLabel> {
self.sessions
.iter()
.map(|(open, _)| TimeAxisLabel {
time: *open,
text: format!("Open {}", open.format("%b %-d")),
color: Color32::from_rgb(41, 98, 255),
})
.collect()
}
}
let mut chart = sample_chart();
chart.attach_primitive(
SeriesId::MAIN,
SessionHighlight {
// 14:30 to 21:00 UTC, the regular hours of a US exchange in winter.
open: Duration::minutes(14 * 60 + 30),
close: Duration::hours(21),
sessions: Vec::new(),
},
);
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(),
)
}

Chart::attach_primitive(series, primitive) accepts the main series, an added bar series or a study and returns an Option<PrimitiveId>. An absent series returns None and drops the supplied primitive. Keep the ID when you need to remove the attachment with detach_primitive; it returns false for an unknown or already detached ID.

The primitive follows its series’ pane and price scale, including pane moves. Its drawing hooks run while that series is visible. Implement only the hooks you need; all have defaults that do nothing. Primitive requires Send.

HookWhen it runsWhat to keep there
attachedOnce, on the first frame after attachmentThe series ID and a cloned egui context if host work needs to request a repaint
data_changedAfter attachment and before drawing a frame whose source bars changedCached domain geometry; bars are oldest first, and a study receives the main bars it computes from
detachedOn detachment after the attachment hook ran, including series removalRelease resources owned by the attachment

Attach once during setup, then call chart.show(ui) each frame. Reattaching every frame would accumulate primitives. Undoing a study removal restores the study without its primitive attachments, so the host must recreate those.

draw receives a painter clipped to the pane’s plot, a PrimitiveView and a PrimitivePass. Choose the pass explicitly to avoid drawing the same geometry three times:

PassPosition
BelowAbove the pane background, grid and sessions; behind its series and studies
SeriesAbove series and studies; below drawings
AboveAbove series, studies and drawings; still below the main pane’s crosshair and legend

Several attachments draw in attachment order within a pass. Read geometry from the current view instead of caching screen coordinates across pan, zoom or resize:

  • view.visible is the visible index range into view.bars.
  • index_to_x maps a fractional index to screen points; whole indices are bar centres. time_to_x interpolates times and returns None without bars.
  • view.mapping gives the plot rectangle, bar spacing and the series’ vertical mapping. price_to_y maps prices on that scale, respecting its mode and inversion.

The session rectangle starts half a bar before its opening bar’s centre and ends half a bar before the closing time, so its edges align with bar boundaries. For dense custom geometry, override draw_merged and visit aligned source ranges with point_groups. The default calls draw without aggregating your payload.

Return TimeAxisLabel values from time_axis_labels, as this scene does, or PriceAxisLabel values from price_axis_labels. The chart places tags, chooses readable text against their fill, and omits price tags off the scale or without room. Time tags suppress underlying axis labels they cover.

For other axis geometry, use draw_price_axis or draw_time_axis; their painters are clipped to the supplied axis rectangle and draw behind labels. The time axis is shared: in time-axis hooks, a study-pane primitive’s vertical mapping is the main pane’s mapping. Use time or x there, rather than its study’s y.

autoscale returns an optional (minimum, maximum) in data values to widen the series’ automatic price fit. None adds nothing. It does not change a manual range. An overlay study on an independent scale fits that scale to its study values only. The custom-series scene includes zero so that lollipop stems remain inside their pane’s automatic range.

hit_test receives the pointer in egui screen points and the current view. Return a CursorIcon for a hit, or None to let testing continue. Reuse the drawing geometry so the hit area follows pan and zoom. Drawings have priority, then attached primitives in reverse attachment order, then built-in series and studies.

A normal attachment reports PointerTarget::Primitive(id) in chart pointer events. Read events with drain_events after show and use them for host actions; a hit test chooses a target and cursor, it does not execute an application command. A custom series’ renderer instead targets its study, as the next guide explains.