use super::{frame::TimeFrame, time_tick_spacing};
use crate::{
error::ChartWarning,
layout::{LABEL_SIZE, PlotArea, fit_text},
metrics::TextMetrics,
scene::{Element, Line, Text, TextAnchor},
spec::{ChartSpec, Stack},
time,
};
pub(super) 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,
}));
}
}
pub(super) 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)
}
pub(super) 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_scale_bar(
frame: &TimeFrame,
unit: Option<&str>,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let plot = frame.plot;
let step = frame.scale.step;
let (top, bottom) = (frame.y(step / 2.0), frame.y(-step / 2.0));
let x = plot.left - 10.0;
for (x1, y1, x2, y2) in [
(x, top, x, bottom),
(x - 4.0, top, x + 4.0, top),
(x - 4.0, bottom, x + 4.0, bottom),
] {
elements.push(Element::Line(Line {
x1,
y1,
x2,
y2,
class: "chartlet-zero",
}));
}
let label = frame.scale.tick_label(step, frame.style);
let content = match unit {
Some(unit) => format!("{label} {unit}"),
None => label,
};
let room = (x - 8.0 - 4.0).max(0.0);
elements.push(Element::Text(Text {
x: x - 8.0,
y: f64::midpoint(top, bottom) + 4.0,
class: "chartlet-tick",
anchor: TextAnchor::End,
content: fit_text(&content, room, LABEL_SIZE, metrics, warnings, "/panes"),
}));
}
pub(super) 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,
}));
}
}
pub(super) 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,
);
if spec.panes[pane_index].stack == Some(Stack::Stream) {
push_scale_bar(frame, unit.as_deref(), elements, warnings, metrics);
} else {
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"),
),
}));
}
}