use std::fmt::Write as _;
use super::{
AREA_CLASSES, LABEL_LINE, LABEL_SIZE, LEGEND_HEIGHT, LEGEND_ROW, PANEL_GUTTER, PANEL_MARGIN,
PlotArea,
annotation::{push_marker, push_marker_label, push_rule, push_zone},
axis::{NumericScale, format_value},
count, fit_text,
labels::LabelSpace,
legend::{add_layer_legend, legend_rows},
panel_plot_pixels,
title::{push_title, title_extra},
tooltip, tooltips_fit,
};
use crate::{
error::ChartWarning,
metrics::TextMetrics,
scene::{Circle, Element, Hook, Line, Polyline, Scene, Text, TextAnchor},
spec::{
ChartSpec, ChartType, Dash, LayerRef, LayerSpec, LegendPlacement, MAX_SERIES, Mark,
NumberStyle, Stroke, Tooltips,
},
time,
time::{Precision, TimeZone},
};
const TIME_TICK_SPACING: u32 = 90;
const YEAR_TICK_SPACING: u32 = 64;
const MINUTE_TICK_SPACING: u32 = 120;
const fn time_tick_spacing(precision: Precision) -> u32 {
match precision {
Precision::Year | Precision::Number(_) => YEAR_TICK_SPACING,
Precision::Month | Precision::Day => TIME_TICK_SPACING,
Precision::Minute => MINUTE_TICK_SPACING,
}
}
const END_LABEL_GAP: f64 = 8.0;
pub(super) const MULTIPLES_MARGIN: f64 = 16.0;
const TIME_INSET: f64 = 6.0;
const MAX_TIME_MARKERS: usize = 60;
const LINE_WEIGHTS: [&str; 4] = [
"",
" chartlet-line-thin",
" chartlet-line-bold",
" chartlet-line-medium",
];
const LINE_DASHES: [&str; 4] = [
"",
" chartlet-line-modeled",
" chartlet-line-dashed",
" chartlet-line-dotted",
];
static LINE_CLASSES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
const BAND_CLASSES: [&str; MAX_SERIES] = [
"chartlet-band chartlet-band-series-1",
"chartlet-band chartlet-band-series-2",
"chartlet-band chartlet-band-series-3",
"chartlet-band chartlet-band-series-4",
];
pub(super) const POINT_CLASSES: [&str; MAX_SERIES] = [
"chartlet-point chartlet-point-series-1",
"chartlet-point chartlet-point-series-2",
"chartlet-point chartlet-point-series-3",
"chartlet-point chartlet-point-series-4",
];
pub(super) fn layout_time(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
if spec.sparkline {
return layout_sparkline(spec, warnings, metrics);
}
let zone = spec.time_zone().unwrap_or_default();
let width = f64::from(spec.width);
let height = f64::from(spec.height);
let left = time_gutter(spec, zone, warnings, metrics);
let end_labels = spec.legend == LegendPlacement::End;
let right = f64::from(super::plot_margin(spec.width))
+ if end_labels {
end_label_room(spec, width, metrics)
} else {
0.0
};
let layered = !end_labels
&& (spec.series_names().len() > 1 || spec.layers().any(|layer| layer.mark == Mark::Ohlc));
let head = -title_extra(spec, width - left - right, metrics);
let legend = if layered {
count(legend_rows(spec, width - left - right, metrics)) * LEGEND_HEIGHT
} else {
0.0
};
let bare = !spec.show_title
&& legend == 0.0
&& spec
.panes
.first()
.is_none_or(|pane| pane.value_axis.title.is_none());
let top = if bare { 14.0 } else { 78.0 - head + legend };
let bottom = if spec.time_axis.title.is_some() {
56.0
} else {
36.0
};
let plot = PlotArea {
left,
top,
width: width - left - right,
height: super::plot_height(height - top - bottom, warnings),
vertical_bars: true,
};
let frames = pane_frames(spec, zone, plot, warnings);
let mut elements = Vec::new();
push_title(
&mut elements,
spec,
plot.left,
plot.width,
metrics,
warnings,
);
for (pane_index, frame) in frames.iter().enumerate() {
let bottom_pane = pane_index + 1 == frames.len();
push_pane_axes(
spec,
pane_index,
frame,
bottom_pane,
&mut elements,
warnings,
metrics,
);
}
if layered {
add_layer_legend(
spec,
LEGEND_ROW - head,
plot.left,
plot.width,
&mut elements,
warnings,
metrics,
);
}
for (pane_index, frame) in frames.iter().enumerate() {
elements.push(frame.hook(pane_index));
draw_pane(
spec,
pane_index,
frame,
Detail::Full,
&mut elements,
warnings,
metrics,
);
}
if end_labels {
push_end_labels(spec, &frames, &mut elements, warnings, metrics);
}
push_time_axis_title(spec, plot, height, &mut elements, warnings, metrics);
Scene {
width: spec.width,
height: spec.height,
elements,
}
}
pub(super) fn multiples_header(
spec: &ChartSpec,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> f64 {
let width = f64::from(spec.width);
push_title(
elements,
spec,
MULTIPLES_MARGIN,
width - 2.0 * MULTIPLES_MARGIN,
metrics,
warnings,
);
let head = title_extra(spec, width - 2.0 * MULTIPLES_MARGIN, metrics);
let mut cursor = LEGEND_ROW + head;
if spec.series_names().len() > 1 {
add_layer_legend(
spec,
LEGEND_ROW + head,
MULTIPLES_MARGIN,
width - 2.0 * MULTIPLES_MARGIN,
elements,
warnings,
metrics,
);
cursor += count(legend_rows(spec, width - 2.0 * MULTIPLES_MARGIN, metrics)) * LEGEND_HEIGHT;
}
if let Some(axis_title) = &spec.value_axis.title {
elements.push(Element::Text(Text {
x: MULTIPLES_MARGIN,
y: cursor + 10.0,
class: "chartlet-axis-title",
anchor: TextAnchor::Start,
content: fit_text(
axis_title,
width - 2.0 * MULTIPLES_MARGIN,
LABEL_SIZE,
metrics,
warnings,
"/valueAxis/title",
),
}));
cursor += 20.0;
}
cursor + 8.0
}
fn layout_sparkline(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
let zone = spec.time_zone().unwrap_or_default();
let inset = 4.0;
let plot = PlotArea {
left: inset - TIME_INSET,
top: inset,
width: f64::from(spec.width) - 2.0 * (inset - TIME_INSET),
height: f64::from(spec.height) - 2.0 * inset,
vertical_bars: true,
};
let frames = pane_frames(spec, zone, plot, warnings);
let mut elements = Vec::new();
for (pane_index, frame) in frames.iter().enumerate() {
elements.push(frame.hook(pane_index));
draw_pane(
spec,
pane_index,
frame,
Detail::Spark,
&mut elements,
warnings,
metrics,
);
}
Scene {
width: spec.width,
height: spec.height,
elements,
}
}
fn push_time_axis_title(
spec: &ChartSpec,
plot: PlotArea,
height: f64,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
if let Some(title) = &spec.time_axis.title {
let title = fit_text(
title,
plot.width,
LABEL_SIZE,
metrics,
warnings,
"/timeAxis/title",
);
elements.push(Element::Text(Text {
x: plot.left + plot.width / 2.0,
y: height - 14.0,
class: "chartlet-axis-title",
anchor: TextAnchor::Middle,
content: title,
}));
}
}
fn end_label_room(spec: &ChartSpec, width: f64, metrics: &impl TextMetrics) -> f64 {
let widest = spec
.data_layers()
.map(|entry| metrics.width(&end_label(spec, entry), LABEL_SIZE))
.fold(0.0, f64::max);
(widest + END_LABEL_GAP).min(width / 3.0)
}
fn end_label(spec: &ChartSpec, entry: LayerRef) -> String {
let words = spec.locale.words();
let name = entry.layer.name.as_deref().unwrap_or(words.value);
if entry.layer.modeled {
format!("{name} ({})", words.modeled)
} else {
name.to_owned()
}
}
fn push_end_labels(
spec: &ChartSpec,
frames: &[TimeFrame],
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let width = f64::from(spec.width);
for (pane_index, frame) in frames.iter().enumerate() {
let mut labels: Vec<(f64, String, String)> = spec
.data_layers()
.filter(|entry| entry.pane == pane_index)
.filter_map(|entry| {
let (_, value) = *spec.drawn_points(entry, frame.zone).last()?;
let path = format!("/panes/{}/layers/{}/name", entry.pane, entry.local);
Some((frame.y(value) + 4.0, end_label(spec, entry), path))
})
.collect();
crate::sort::by(&mut labels, |a, b| a.0.total_cmp(&b.0));
let plot = frame.plot;
let mut floor = plot.top + LABEL_SIZE;
for label in &mut labels {
label.0 = label.0.max(floor);
floor = label.0 + LABEL_LINE;
}
let overflow = labels
.last()
.map_or(0.0, |last| (last.0 - (plot.top + plot.height)).max(0.0));
let x = plot.left + plot.width + END_LABEL_GAP;
for (y, name, path) in labels {
elements.push(Element::Text(Text {
x,
y: y - overflow,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content: fit_text(&name, width - x - 4.0, LABEL_SIZE, metrics, warnings, &path),
}));
}
}
}
fn panel_lines(
text: &str,
width: f64,
size: f64,
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
path: &str,
) -> Vec<String> {
match super::title::two_lines(text, width, size, metrics) {
Some((first, rest)) => vec![
first.to_owned(),
fit_text(rest, width, size, metrics, warnings, path),
],
None => vec![fit_text(text, width, size, metrics, warnings, path)],
}
}
pub(super) type PanelHead = (Vec<String>, Vec<String>, &'static str);
const PANEL_TITLE_LINE: f64 = 16.0;
const PANEL_NOTE_LINE: f64 = 15.0;
pub(super) fn panel_heads(
spec: &ChartSpec,
width: f64,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> (Vec<PanelHead>, f64, usize) {
let heads: Vec<PanelHead> = spec
.panes
.iter()
.enumerate()
.map(|(index, pane)| {
let title = panel_lines(
pane.title.as_deref().unwrap_or_default(),
width,
13.0,
metrics,
warnings,
&format!("/panes/{index}/title"),
);
let note = pane.note.as_deref().map_or_else(Vec::new, |note| {
panel_lines(
note,
width,
LABEL_SIZE,
metrics,
warnings,
&format!("/panes/{index}/note"),
)
});
let class = if pane.note_emphasis {
"chartlet-panel-note chartlet-panel-note-strong"
} else {
"chartlet-panel-note"
};
(title, note, class)
})
.collect();
let title_lines = heads
.iter()
.map(|(title, ..)| title.len())
.max()
.unwrap_or(1);
let note_lines = heads
.iter()
.map(|(_, note, _)| note.len())
.max()
.unwrap_or(0);
let height = 14.0 + PANEL_TITLE_LINE * count(title_lines) + PANEL_NOTE_LINE * count(note_lines);
(heads, height, title_lines)
}
pub(super) fn push_panel_head(
(title, note, note_class): &PanelHead,
title_lines: usize,
plot: PlotArea,
cell_top: f64,
elements: &mut Vec<Element>,
) {
for (index, line) in title.iter().enumerate() {
elements.push(Element::Text(Text {
x: plot.left,
y: cell_top + 18.0 + PANEL_TITLE_LINE * count(index),
class: "chartlet-panel-title",
anchor: TextAnchor::Start,
content: line.clone(),
}));
}
let below = cell_top + 18.0 + PANEL_TITLE_LINE * count(title_lines - 1);
for (index, line) in note.iter().enumerate() {
elements.push(Element::Text(Text {
x: plot.left,
y: below + PANEL_NOTE_LINE * count(index + 1),
class: note_class,
anchor: TextAnchor::Start,
content: line.clone(),
}));
}
}
fn push_multiples_time_title(
spec: &ChartSpec,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let (width, height) = (f64::from(spec.width), f64::from(spec.height));
if let Some(axis_title) = &spec.time_axis.title {
elements.push(Element::Text(Text {
x: width / 2.0,
y: height - 14.0,
class: "chartlet-axis-title",
anchor: TextAnchor::Middle,
content: fit_text(
axis_title,
width - 2.0 * MULTIPLES_MARGIN,
LABEL_SIZE,
metrics,
warnings,
"/timeAxis/title",
),
}));
}
}
pub(super) fn layout_multiples(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
let zone = spec.time_zone().unwrap_or_default();
let width = f64::from(spec.width);
let height = f64::from(spec.height);
let columns = spec.multiples_columns();
let panels = spec.panes.len();
let rows = panels.div_ceil(usize::try_from(columns).expect("columns are limited"));
let mut elements = Vec::new();
let grid_top = multiples_header(spec, &mut elements, warnings, metrics);
let grid_bottom = height
- if spec.time_axis.title.is_some() {
36.0
} else {
12.0
};
let cell_width = (width - 2.0 * MULTIPLES_MARGIN) / f64::from(columns);
let cell_height = (grid_bottom - grid_top) / count(rows);
let panel_gutter = multiples_gutter(spec, zone, warnings, metrics);
let (heads, head_height, title_lines) = panel_heads(
spec,
cell_width - panel_gutter - f64::from(PANEL_MARGIN),
warnings,
metrics,
);
let (span, slots) = time_axis(spec, zone);
let precision = spec.time_precision(zone);
let shared = time_scale(spec, zone, None);
let max_ticks = usize::try_from(
(panel_plot_pixels(spec.width, columns) / time_tick_spacing(spec.observed_precision(zone)))
.max(2),
)
.expect("a usize is at least 32 bits wide");
for (pane_index, head) in heads.iter().enumerate() {
let column = pane_index % usize::try_from(columns).expect("columns are limited");
let row = pane_index / usize::try_from(columns).expect("columns are limited");
let cell_left = MULTIPLES_MARGIN + cell_width * count(column);
let cell_top = grid_top + cell_height * count(row);
let plot = PlotArea {
left: cell_left + panel_gutter,
top: cell_top + head_height,
width: cell_width - panel_gutter - f64::from(PANEL_MARGIN),
height: cell_height - head_height - 28.0,
vertical_bars: true,
};
let frame = TimeFrame {
plot,
span,
slots: slots.clone(),
zone,
precision,
tick_step: spec.time_axis.step,
sub_day: spec.observed_precision(zone) == Precision::Minute,
unit: spec.axis_unit(pane_index).map(str::to_owned),
scale: if spec.independent_axes {
time_scale(spec, zone, Some(pane_index))
} else {
shared
},
style: spec.number_style(),
reversed: spec.time_axis.reverse,
};
push_panel_head(head, title_lines, plot, cell_top, &mut elements);
let unit = fitted_unit(
spec.axis_unit(pane_index),
&frame,
cell_left,
"/valueAxis/unit",
warnings,
metrics,
);
push_value_grid(&frame, unit.as_deref(), &mut elements);
push_time_ticks(&frame, max_ticks, true, width, metrics, &mut elements);
elements.push(frame.hook(pane_index));
draw_pane(
spec,
pane_index,
&frame,
Detail::Compact,
&mut elements,
warnings,
metrics,
);
}
if cell_height - head_height - 28.0 < 60.0 {
warnings.push(ChartWarning::new(
"dense_chart",
"/height",
"the panels are less than 60 pixels tall; raise height or use more columns",
));
}
push_multiples_time_title(spec, &mut elements, warnings, metrics);
Scene {
width: spec.width,
height: spec.height,
elements,
}
}
pub(crate) struct TimeFrame {
pub(super) plot: PlotArea,
span: (i64, i64),
slots: Option<Vec<i64>>,
pub(crate) zone: TimeZone,
pub(crate) precision: Precision,
tick_step: Option<f64>,
sub_day: bool,
pub(crate) unit: Option<String>,
scale: NumericScale,
pub(crate) style: NumberStyle,
reversed: bool,
}
impl TimeFrame {
pub(crate) fn x(&self, epoch: i64) -> f64 {
self.oriented(match &self.slots {
Some(slots) => slot_x(
slots.partition_point(|slot| *slot < epoch),
slots.len(),
self.plot,
),
None => time_x(epoch, self.span, self.plot),
})
}
fn oriented(&self, x: f64) -> f64 {
if self.reversed {
2.0 * self.plot.left + self.plot.width - x
} else {
x
}
}
pub(super) fn edges(&self) -> (f64, f64) {
let (left, right) = (self.plot.left, self.plot.left + self.plot.width);
if self.reversed {
(right, left)
} else {
(left, right)
}
}
pub(super) fn x_until(&self, epoch: i64) -> f64 {
self.oriented(match &self.slots {
Some(slots) => slot_x(
slots
.partition_point(|slot| *slot <= epoch)
.saturating_sub(1),
slots.len(),
self.plot,
),
None => time_x(epoch, self.span, self.plot),
})
}
pub(crate) fn y(&self, value: f64) -> f64 {
self.scale
.map(value, self.plot.top + self.plot.height, self.plot.top)
}
fn hook(&self, pane: usize) -> Element {
#[allow(clippy::cast_precision_loss)]
let x = match &self.slots {
Some(slots) => slots
.iter()
.map(|slot| (*slot as f64, self.x(*slot)))
.collect(),
None => vec![
(self.span.0 as f64, self.x(self.span.0)),
(self.span.1 as f64, self.x(self.span.1)),
],
};
let (min, max) = self.scale.ends();
Element::Hook(Hook::Plot {
pane,
x,
y: [(min, self.y(min)), (max, self.y(max))],
log: self.scale.is_log(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Detail {
Full,
Compact,
Spark,
}
fn multiples_gutter(
spec: &ChartSpec,
zone: TimeZone,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> f64 {
let gutter = f64::from(PANEL_GUTTER);
let style = spec.number_style();
let panes: Vec<Option<usize>> = if spec.independent_axes {
(0..spec.panes.len()).map(Some).collect()
} else {
vec![None]
};
let labels: Vec<String> = panes
.into_iter()
.flat_map(|pane| {
let scale = time_scale(spec, zone, pane);
scale
.ticks()
.map(move |value| scale.tick_label(value, style))
.collect::<Vec<_>>()
})
.collect();
let width = |text: &str| super::WithReserve(metrics).width(text, LABEL_SIZE);
let plain = labels.iter().map(|label| width(label)).fold(0.0, f64::max);
let with_unit = spec.value_axis.unit.as_deref().map_or(plain, |unit| {
labels
.iter()
.map(|label| width(&format!("{label} {unit}")))
.fold(0.0, f64::max)
});
let cell =
(f64::from(spec.width) - 2.0 * MULTIPLES_MARGIN) / f64::from(spec.multiples_columns());
let most = (cell * 0.4).max(gutter);
if plain + 16.0 > most {
warnings.push(ChartWarning::new(
"dense_chart",
"/columns",
"the value tick labels need more room than the panels leave them; use fewer columns or a wider chart",
));
}
gutter.max(with_unit + 16.0).min(most)
}
fn time_gutter(
spec: &ChartSpec,
zone: TimeZone,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> f64 {
let gutter = f64::from(super::axis_gutter(spec.width));
let widest = (0..spec.panes.len())
.flat_map(|pane| {
let scale = time_scale(spec, zone, Some(pane));
let style = spec.pane_style(pane);
let unit = spec
.axis_unit(pane)
.map(|unit| format!(" {unit}"))
.unwrap_or_default();
scale
.ticks()
.map(move |value| format!("{}{unit}", scale.tick_label(value, style)))
.collect::<Vec<_>>()
})
.map(|label| super::WithReserve(metrics).width(&label, LABEL_SIZE))
.fold(0.0, f64::max);
let needed = widest + 22.0;
let most = f64::from(spec.width) * 0.4;
if needed > most.max(gutter) {
warnings.push(ChartWarning::new(
"dense_chart",
"/width",
"the value tick labels are wider than the chart leaves them and are cut at its left edge; raise width or use fewer digits",
));
}
if needed <= gutter {
needed.max(28.0)
} else {
needed.min(most).max(gutter)
}
}
fn time_scale(spec: &ChartSpec, zone: TimeZone, pane: Option<usize>) -> NumericScale {
let inside = |pane_index: usize| pane.is_none_or(|pane| pane == pane_index);
let layers = || {
spec.indexed_layers()
.filter(|entry| inside(entry.pane))
.map(|entry| entry.layer)
};
let mut values = Vec::new();
for entry in spec.data_layers().filter(|entry| inside(entry.pane)) {
values.extend(
spec.drawn_points(entry, zone)
.into_iter()
.map(|(_, value)| value),
);
for (_, lower, upper) in entry.layer.resolved_band(zone) {
values.push(lower);
values.push(upper);
}
for (_, [_, high, low, _]) in entry.layer.resolved_candles(zone) {
values.push(high);
values.push(low);
}
}
values.extend(
layers()
.filter(|layer| layer.mark == Mark::Annotation)
.filter_map(|layer| layer.value),
);
values.extend(
layers()
.filter(|layer| layer.mark == Mark::Band)
.flat_map(|layer| [layer.bottom, layer.top])
.flatten(),
);
let area = layers().any(|layer| layer.mark == Mark::Area);
let axis = match pane {
Some(pane) if spec.chart_type != ChartType::Multiples => &spec.panes[pane].value_axis,
_ => &spec.value_axis,
};
NumericScale::for_axis(values.into_iter(), area, axis)
}
pub(crate) fn tooltip_name(spec: &ChartSpec, entry: LayerRef) -> Option<String> {
let title = if spec.chart_type == ChartType::Multiples {
spec.panes[entry.pane].title.clone()
} else {
None
};
match (title, &entry.layer.name) {
(Some(title), Some(name)) => Some(format!("{title} · {name}")),
(Some(title), None) => Some(title),
(None, name) => name.clone(),
}
}
fn draw_pane(
spec: &ChartSpec,
pane: usize,
frame: &TimeFrame,
detail: Detail,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let entries: Vec<LayerRef> = spec
.indexed_layers()
.filter(|entry| entry.pane == pane)
.collect();
let mut space = LabelSpace::new(frame, &entries);
let mut rule_labels = Vec::new();
for entry in entries
.iter()
.filter(|entry| entry.layer.mark == Mark::Band)
{
elements.push(Element::Hook(Hook::Layer(entry.global)));
push_zone(
spec,
*entry,
frame,
elements,
&mut rule_labels,
&mut space,
warnings,
metrics,
);
elements.push(Element::Hook(Hook::End));
}
for entry in entries
.iter()
.filter(|entry| entry.layer.mark == Mark::Area)
{
elements.push(Element::Hook(Hook::Layer(entry.global)));
push_area(spec, *entry, frame, elements);
elements.push(Element::Hook(Hook::End));
}
for entry in entries
.iter()
.filter(|entry| entry.layer.is_data() && entry.layer.has_band())
{
elements.push(Element::Hook(Hook::Layer(entry.global)));
push_band(spec, *entry, frame, elements);
elements.push(Element::Hook(Hook::End));
}
for entry in entries.iter().filter(|entry| entry.layer.is_rule()) {
elements.push(Element::Hook(Hook::Layer(entry.global)));
push_rule(
*entry,
frame,
elements,
&mut rule_labels,
&mut space,
warnings,
metrics,
);
elements.push(Element::Hook(Hook::End));
}
for entry in entries
.iter()
.filter(|entry| entry.layer.mark == Mark::Ohlc)
{
elements.push(Element::Hook(Hook::Layer(entry.global)));
crate::ohlc::push_candles(spec, *entry, frame, elements);
elements.push(Element::Hook(Hook::End));
}
for entry in entries
.iter()
.filter(|entry| entry.layer.is_data() && entry.layer.mark != Mark::Ohlc)
{
elements.push(Element::Hook(Hook::Layer(entry.global)));
push_line(spec, *entry, frame, detail, elements, warnings, metrics);
elements.push(Element::Hook(Hook::End));
}
let markers: Vec<LayerRef> = entries
.iter()
.filter(|entry| entry.layer.is_marker())
.copied()
.collect();
let radius = if detail == Detail::Full { 6.0 } else { 4.5 };
for entry in &markers {
elements.push(Element::Hook(Hook::Layer(entry.global)));
push_marker(*entry, frame, radius, elements, &mut space);
elements.push(Element::Hook(Hook::End));
}
for entry in &markers {
push_marker_label(
*entry,
frame,
radius,
&mut rule_labels,
&mut space,
warnings,
metrics,
);
}
elements.extend(rule_labels);
}
fn push_area(spec: &ChartSpec, entry: LayerRef, frame: &TimeFrame, elements: &mut Vec<Element>) {
let explicit = entry.layer.resolved_color().is_some();
let palette = spec.palette_index(entry.layer);
let base = spec.stack_base(entry, frame.zone);
let segments = if base.is_some() {
vec![spec.drawn_points(entry, frame.zone)]
} else {
entry.layer.resolved_segments(frame.zone)
};
for segment in segments {
if segment.len() < 2 {
continue;
}
let (first, last) = (segment[0].0, segment[segment.len() - 1].0);
let mut outline = entry.layer.curve.points(
segment
.iter()
.map(|(epoch, value)| (frame.x(*epoch), frame.y(*value)))
.collect(),
);
extend_last_step(entry.layer, frame, last, &mut outline);
let end = outline.last().map_or(frame.x(last), |point| point.0);
if let Some(base) = &base {
let below = entry.layer.curve.points(
segment
.iter()
.zip(base)
.map(|((epoch, _), below)| (frame.x(*epoch), frame.y(*below)))
.collect(),
);
outline.extend(below.into_iter().rev());
} else {
outline.push((end, frame.y(0.0)));
outline.push((frame.x(first), frame.y(0.0)));
}
elements.push(Element::Polyline(Polyline {
points: outline,
class: if explicit {
"chartlet-area"
} else {
AREA_CLASSES[palette]
},
topic: None,
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip: None,
}));
}
}
fn push_band(spec: &ChartSpec, entry: LayerRef, frame: &TimeFrame, elements: &mut Vec<Element>) {
let mut runs = vec![Vec::new()];
for point in &entry.layer.points {
let Ok(epoch) = point.time.resolve(frame.zone) else {
continue;
};
match (point.lower, point.upper) {
(Some(lower), Some(upper)) if point.value.is_some() => runs
.last_mut()
.expect("there is always a current run")
.push((epoch, lower, upper)),
_ => runs.push(Vec::new()),
}
}
for band in runs.into_iter().filter(|run| !run.is_empty()) {
push_band_run(spec, entry, frame, &band, elements);
}
}
fn push_band_run(
spec: &ChartSpec,
entry: LayerRef,
frame: &TimeFrame,
band: &[(i64, f64, f64)],
elements: &mut Vec<Element>,
) {
let mut outline: Vec<(f64, f64)> = band
.iter()
.map(|(epoch, _, upper)| (frame.x(*epoch), frame.y(*upper)))
.collect();
outline.extend(
band.iter()
.rev()
.map(|(epoch, lower, _)| (frame.x(*epoch), frame.y(*lower))),
);
let explicit = entry.layer.resolved_color().is_some();
let palette = spec.palette_index(entry.layer);
if entry.layer.modeled {
elements.push(Element::Polyline(Polyline {
points: outline.clone(),
class: if explicit {
"chartlet-band"
} else {
BAND_CLASSES[palette]
},
topic: None,
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip: None,
}));
elements.push(Element::Polyline(Polyline {
points: outline,
class: "chartlet-hatch",
topic: None,
series_index: None,
style_index: Some(entry.global),
tooltip: None,
}));
} else {
elements.push(Element::Polyline(Polyline {
points: outline,
class: if explicit {
"chartlet-band"
} else {
BAND_CLASSES[palette]
},
topic: None,
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip: None,
}));
}
}
fn observation_tooltip(
spec: &ChartSpec,
frame: &TimeFrame,
(epoch, value): (i64, f64),
band: Option<(f64, f64)>,
name: Option<&str>,
precision: Precision,
) -> String {
let mut text = tooltip(
&precision.format(epoch, frame.zone),
value,
frame.style,
name,
);
if let Some(unit) = &frame.unit {
write!(text, " {unit}").expect("writing to String cannot fail");
}
if let Some((lower, upper)) = band {
write!(
text,
" ({} {} {} {})",
spec.locale.words().range,
format_value(lower, frame.style),
spec.locale.words().to,
format_value(upper, frame.style)
)
.expect("writing to String cannot fail");
}
text
}
fn push_line(
spec: &ChartSpec,
entry: LayerRef,
frame: &TimeFrame,
detail: Detail,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let layer = entry.layer;
let points = layer.resolved_points(frame.zone);
let explicit = layer.resolved_color().is_some();
let palette = spec.palette_index(layer);
let is_point = layer.mark == Mark::Point;
if !is_point {
push_line_segments(spec, entry, frame, elements);
}
let spark = detail == Detail::Spark;
let dense = points.len() > MAX_TIME_MARKERS;
let markers = !spark && (!dense || is_point) && layer.markers != Some(false);
let fits = || {
let xs: Vec<f64> = points.iter().map(|(epoch, _)| frame.x(*epoch)).collect();
spec.tooltips == Tooltips::Observations && tooltips_fit(&xs)
};
let hits = !markers && fits();
let marker_tooltips = !dense || fits();
if !markers && !hits && !spark {
return;
}
let last = points.len().saturating_sub(1);
let band = layer.resolved_band(frame.zone);
let name = tooltip_name(spec, entry);
let precision = spec.layer_precision(layer, frame.zone);
let drawn = spec.drawn_points(entry, frame.zone);
let mut labels_end = f64::NEG_INFINITY;
for (index, (epoch, value)) in points.iter().enumerate() {
let x = frame.x(*epoch);
let y = frame.y(drawn[index].1);
let text = observation_tooltip(
spec,
frame,
(*epoch, *value),
band.get(index).map(|(_, lower, upper)| (*lower, *upper)),
name.as_deref(),
precision,
);
let point = |radius: f64, tooltip: Option<String>| {
Element::Circle(Circle {
cx: x,
cy: y,
radius,
class: if explicit {
"chartlet-point"
} else {
POINT_CLASSES[palette]
},
topic: None,
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip,
})
};
if !markers {
if spark && index == last {
elements.push(point(2.5, (!hits).then(|| text.clone())));
}
if hits {
elements.push(Element::Circle(Circle {
cx: x,
cy: y,
radius: 4.0,
class: "chartlet-hit",
topic: None,
series_index: None,
style_index: None,
tooltip: Some(text),
}));
}
continue;
}
elements.push(point(
if detail == Detail::Full && !dense {
4.0
} else {
2.5
},
marker_tooltips.then_some(text),
));
if detail == Detail::Full && spec.show_values && !dense {
let content = format_value(*value, frame.style);
let half = metrics.width(&content, LABEL_SIZE) / 2.0;
if x - half < labels_end + 4.0 {
crate::layout::warn_if_labels_omitted(true, warnings);
} else {
labels_end = x + half;
elements.push(Element::Text(Text {
x,
y: y - 10.0,
class: "chartlet-value",
anchor: TextAnchor::Middle,
content,
}));
}
}
}
}
fn extend_last_step(layer: &LayerSpec, frame: &TimeFrame, last: i64, points: &mut Vec<(f64, f64)>) {
let Some(end) = layer
.step_end
.as_ref()
.and_then(|time| time.resolve(frame.zone).ok())
else {
return;
};
let last_point = layer.points.last();
if last_point.is_some_and(|point| point.value.is_some())
&& last_point.and_then(|point| point.time.resolve(frame.zone).ok()) == Some(last)
&& let Some(&(_, y)) = points.last()
{
points.push((frame.x(end), y));
}
}
fn push_line_segments(
spec: &ChartSpec,
entry: LayerRef,
frame: &TimeFrame,
elements: &mut Vec<Element>,
) {
let layer = entry.layer;
let class = line_class(spec, entry);
let explicit = layer.resolved_color().is_some();
let segments = if spec.stack_base(entry, frame.zone).is_some() {
vec![spec.drawn_points(entry, frame.zone)]
} else {
layer.resolved_segments(frame.zone)
};
for segment in segments {
if segment.len() < 2 {
continue;
}
let mut points = layer.curve.points(
segment
.iter()
.map(|(epoch, value)| (frame.x(*epoch), frame.y(*value)))
.collect(),
);
extend_last_step(layer, frame, segment[segment.len() - 1].0, &mut points);
elements.push(Element::Polyline(Polyline {
points,
class,
topic: None,
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip: None,
}));
}
}
pub(super) fn line_class(spec: &ChartSpec, entry: LayerRef) -> &'static str {
let layer = entry.layer;
let color = if layer.resolved_color().is_some() {
0
} else {
spec.palette_index(layer) + 1
};
let dash = match (layer.effective_dash(), layer.modeled) {
(Dash::Solid, _) => 0,
(Dash::Dashed, true) => 1,
(Dash::Dashed, false) => 2,
(Dash::Dotted, _) => 3,
};
let weight = match layer.stroke {
Stroke::Regular => 0,
Stroke::Thin => 1,
Stroke::Bold => 2,
Stroke::Medium => 3,
};
let classes = LINE_CLASSES.get_or_init(|| {
let mut classes = Vec::new();
for color in 0..=MAX_SERIES {
let color = if color == 0 {
String::new()
} else {
format!(" chartlet-line-series-{color}")
};
for dash in LINE_DASHES {
for weight in LINE_WEIGHTS {
classes.push(format!("chartlet-line{weight}{dash}{color}"));
}
}
}
classes
});
&classes[(color * LINE_DASHES.len() + dash) * LINE_WEIGHTS.len() + weight]
}
fn fitted_unit(
unit: Option<&str>,
frame: &TimeFrame,
left_edge: f64,
path: &str,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Option<String> {
let unit = unit?;
let widest = frame
.scale
.ticks()
.map(|value| metrics.width(&frame.scale.tick_label(value, frame.style), LABEL_SIZE))
.fold(0.0, f64::max);
let room = frame.plot.left - 10.0 - left_edge - widest - 4.0;
let fitted = fit_text(unit, room.max(0.0), LABEL_SIZE, metrics, warnings, path);
(fitted != "…").then_some(fitted)
}
fn push_value_grid(frame: &TimeFrame, unit: Option<&str>, elements: &mut Vec<Element>) {
let plot = frame.plot;
let ticks: Vec<(f64, f64)> = frame
.scale
.ticks()
.map(|value| (value, frame.y(value)))
.collect();
let spacing = ticks
.windows(2)
.map(|pair| (pair[1].1 - pair[0].1).abs())
.fold(f64::INFINITY, f64::min);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let step = if spacing.is_finite() && spacing < LABEL_SIZE + 2.0 {
((LABEL_SIZE + 2.0) / spacing.max(1.0)).ceil() as usize
} else {
1
};
let top = ticks
.iter()
.enumerate()
.filter(|(index, _)| index % step == 0)
.min_by(|(_, a), (_, b)| a.1.total_cmp(&b.1))
.map(|(index, _)| index);
for (index, &(value, y)) in ticks.iter().enumerate() {
elements.push(Element::Line(Line {
x1: plot.left,
y1: y,
x2: plot.left + plot.width,
y2: y,
class: if frame.scale.is_zero(value) {
"chartlet-zero"
} else {
"chartlet-grid"
},
}));
if index % step != 0 {
continue;
}
let label = frame.scale.tick_label(value, frame.style);
elements.push(Element::Text(Text {
x: plot.left - 10.0,
y: y + 4.0,
class: "chartlet-tick",
anchor: TextAnchor::End,
content: match unit {
Some(unit) if Some(index) == top => format!("{label} {unit}"),
_ => label,
},
}));
}
}
fn push_time_ticks(
frame: &TimeFrame,
max_ticks: usize,
labels: bool,
canvas_width: f64,
metrics: &impl TextMetrics,
elements: &mut Vec<Element>,
) {
let plot = frame.plot;
let sub_day = frame.sub_day;
let ticks = match &frame.slots {
Some(slots) => {
time::collapsed_ticks(slots, frame.zone, max_ticks, sub_day, frame.tick_step)
}
None => time::ticks(
frame.span.0,
frame.span.1,
frame.zone,
max_ticks,
sub_day,
frame.tick_step,
),
};
let mut last_end = f64::NEG_INFINITY;
for tick in ticks {
let x = frame.x(tick.epoch);
elements.push(Element::Line(Line {
x1: x,
y1: plot.top,
x2: x,
y2: plot.top + plot.height,
class: "chartlet-grid",
}));
if !labels {
continue;
}
let half = metrics.width(&tick.label, LABEL_SIZE) / 2.0;
let x = x.min(canvas_width - 4.0 - half).max(4.0 + half);
let (start, end) = if frame.reversed {
(-(x + half), -(x - half))
} else {
(x - half, x + half)
};
if start < last_end + 6.0 {
continue;
}
last_end = end;
elements.push(Element::Text(Text {
x,
y: plot.top + plot.height + 24.0,
class: "chartlet-tick",
anchor: TextAnchor::Middle,
content: tick.label,
}));
}
}
const PANE_GAP: f64 = 36.0;
const MIN_PANE_HEIGHT: f64 = 40.0;
fn pane_frames(
spec: &ChartSpec,
zone: TimeZone,
plot: PlotArea,
warnings: &mut Vec<ChartWarning>,
) -> Vec<TimeFrame> {
let (span, slots) = time_axis(spec, zone);
let precision = spec.time_precision(zone);
let panes = spec.panes.len();
let ratios: u32 = spec.panes.iter().map(|pane| pane.height_ratio).sum();
let available = plot.height - PANE_GAP * count(panes - 1);
let mut top = plot.top;
let mut frames = Vec::new();
for (pane_index, pane) in spec.panes.iter().enumerate() {
let height = if panes == 1 {
plot.height
} else {
available * f64::from(pane.height_ratio) / f64::from(ratios)
};
if panes > 1 && height < MIN_PANE_HEIGHT {
warnings.push(ChartWarning::new(
"dense_chart",
format!("/panes/{pane_index}/heightRatio"),
"the pane is less than 40 pixels tall; raise height or its heightRatio",
));
}
frames.push(TimeFrame {
plot: PlotArea {
top,
height,
..plot
},
span,
slots: slots.clone(),
zone,
precision,
tick_step: spec.time_axis.step,
sub_day: spec.observed_precision(zone) == Precision::Minute,
unit: spec.axis_unit(pane_index).map(str::to_owned),
scale: time_scale(spec, zone, Some(pane_index)),
style: spec.pane_style(pane_index),
reversed: spec.time_axis.reverse,
});
top += height + PANE_GAP;
}
frames
}
fn push_pane_axes(
spec: &ChartSpec,
pane_index: usize,
frame: &TimeFrame,
bottom_pane: bool,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let plot = frame.plot;
let unit = fitted_unit(
spec.axis_unit(pane_index),
frame,
4.0,
&format!("/panes/{pane_index}/valueAxis/unit"),
warnings,
metrics,
);
push_value_grid(frame, unit.as_deref(), elements);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let plot_width = frame.plot.width.max(0.0) as u32;
let max_ticks = usize::try_from(
(plot_width / time_tick_spacing(spec.observed_precision(frame.zone))).max(2),
)
.expect("a usize is at least 32 bits wide");
push_time_ticks(
frame,
max_ticks,
bottom_pane,
f64::from(spec.width),
metrics,
elements,
);
if let Some(title) = spec.panes[pane_index].value_axis.title.as_deref() {
elements.push(Element::Text(Text {
x: plot.left,
y: plot.top - 20.0,
class: "chartlet-axis-title",
anchor: TextAnchor::Start,
content: fit_text(
title,
plot.width,
LABEL_SIZE,
metrics,
warnings,
&format!("/panes/{pane_index}/valueAxis/title"),
),
}));
}
}
fn time_span(spec: &ChartSpec, zone: TimeZone) -> (i64, i64) {
let mut min = i64::MAX;
let mut max = i64::MIN;
let data = spec
.data_layers()
.flat_map(|entry| entry.layer.resolved_times(zone));
let rules = spec
.layers()
.flat_map(|layer| [&layer.time, &layer.from, &layer.to, &layer.step_end])
.chain([&spec.time_axis.min, &spec.time_axis.max])
.filter_map(Option::as_ref)
.filter_map(|time| time.resolve(zone).ok());
for epoch in data.chain(rules) {
min = min.min(epoch);
max = max.max(epoch);
}
(min, max)
}
fn time_axis(spec: &ChartSpec, zone: TimeZone) -> ((i64, i64), Option<Vec<i64>>) {
let slots = spec.time_slots(zone);
let span = match slots.as_deref() {
Some([first, .., last]) => (*first, *last),
_ => time_span(spec, zone),
};
(span, slots)
}
fn slot_x(index: usize, total: usize, plot: PlotArea) -> f64 {
let last = total.saturating_sub(1);
let ratio = count(index.min(last)) / count(last.max(1));
plot.left + TIME_INSET + ratio * (plot.width - 2.0 * TIME_INSET)
}
fn time_x(epoch: i64, span: (i64, i64), plot: PlotArea) -> f64 {
#[allow(clippy::cast_precision_loss)]
let width = (span.1 - span.0).max(1) as f64;
#[allow(clippy::cast_precision_loss)]
let ratio = (epoch - span.0) as f64 / width;
plot.left + TIME_INSET + ratio * (plot.width - 2.0 * TIME_INSET)
}