use super::TIME_INSET;
use crate::{
error::ChartWarning,
layout::{LABEL_SIZE, PlotArea, axis::NumericScale, count},
metrics::TextMetrics,
scene::{Element, Hook},
spec::{ChartSpec, ChartType, Mark, NumberStyle},
time::{Precision, TimeZone},
};
pub(crate) struct TimeFrame {
pub(in crate::layout) plot: PlotArea,
pub(super) span: (i64, i64),
pub(super) slots: Option<Vec<i64>>,
pub(crate) zone: TimeZone,
pub(crate) precision: Precision,
pub(super) tick_step: Option<f64>,
pub(super) sub_day: bool,
pub(crate) unit: Option<String>,
pub(super) scale: NumericScale,
pub(crate) style: NumberStyle,
pub(super) 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(in crate::layout) 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(in crate::layout) 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)
}
pub(super) 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)]
pub(super) enum Detail {
Full,
Compact,
Spark,
}
pub(super) 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)
}
}
pub(super) 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)
}
const PANE_GAP: f64 = 36.0;
const MIN_PANE_HEIGHT: f64 = 40.0;
pub(super) 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 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)
}
pub(super) 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)
}