use super::{
AREA_CLASSES, LABEL_LINE, LABEL_SIZE, LEGEND_HEIGHT, LEGEND_ROW, PlotArea, count, fit_text,
series_bar_class,
timechart::{POINT_CLASSES, line_class},
title::two_lines,
};
use crate::{
error::ChartWarning,
metrics::TextMetrics,
scene::{Circle, Element, Polyline, Rect, Text, TextAnchor},
spec::{ChartSpec, Dataset, LayerRef, Mark},
};
const LEGEND_LINE: f64 = 24.0;
const LEGEND_SWATCH: f64 = 16.0;
struct LegendEntry<'a> {
entry: LayerRef<'a>,
name: String,
wrapped: Option<(String, String)>,
x: f64,
row: usize,
}
fn legend_entries<'a>(
spec: &'a ChartSpec,
left: f64,
available_width: f64,
metrics: &impl TextMetrics,
) -> Vec<LegendEntry<'a>> {
let mut layers: Vec<LayerRef> = Vec::new();
for entry in spec.data_layers() {
if !layers
.iter()
.any(|known| known.layer.name == entry.layer.name)
{
layers.push(entry);
}
}
let words = spec.locale.words();
let sample = LEGEND_LINE + 8.0;
let (mut x, mut row) = (left, 0);
let mut entries = Vec::new();
for entry in layers {
let name = entry.layer.name.as_deref().unwrap_or(words.value);
let name = if entry.layer.modeled {
format!("{name} ({})", words.modeled)
} else if entry.layer.mark == Mark::Ohlc {
format!("{name} ({})", words.candle_key)
} else {
name.to_owned()
};
let room = available_width - sample;
let wrapped = two_lines(&name, room, LABEL_SIZE, metrics)
.map(|(first, second)| (first.to_owned(), second.to_owned()));
let width = metrics.width(&name, LABEL_SIZE).min(room);
if x > left && (wrapped.is_some() || x + sample + width > left + available_width) {
x = left;
row += 1;
}
let rows = if wrapped.is_some() { 2 } else { 1 };
entries.push(LegendEntry {
entry,
name,
wrapped,
x,
row,
});
if rows == 2 {
x = left + available_width;
row += 1;
} else {
x += sample + width + 20.0;
}
}
entries
}
pub(super) fn legend_rows(
spec: &ChartSpec,
available_width: f64,
metrics: &impl TextMetrics,
) -> usize {
legend_entries(spec, 0.0, available_width, metrics)
.last()
.map_or(1, |entry| {
entry.row + if entry.wrapped.is_some() { 2 } else { 1 }
})
}
pub(super) fn add_layer_legend(
spec: &ChartSpec,
row: f64,
left: f64,
available_width: f64,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
for LegendEntry {
entry,
name,
wrapped,
x,
row: line,
} in legend_entries(spec, left, available_width, metrics)
{
let y = row + count(line) * LEGEND_HEIGHT;
let explicit = entry.layer.resolved_color().is_some();
if entry.layer.mark == Mark::Ohlc {
crate::ohlc::push_legend_sample(x, y, elements);
} else if entry.layer.mark == Mark::Area {
elements.push(Element::Rect(Rect {
x,
y: y + 5.0,
width: LEGEND_LINE,
height: 8.0,
class: if explicit {
"chartlet-area"
} else {
AREA_CLASSES[spec.palette_index(entry.layer)]
},
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip: None,
}));
}
if entry.layer.mark == Mark::Point {
elements.push(Element::Circle(Circle {
cx: x + LEGEND_LINE / 2.0,
cy: y + 5.0,
radius: 4.0,
class: if explicit {
"chartlet-point"
} else {
POINT_CLASSES[spec.palette_index(entry.layer)]
},
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip: None,
}));
} else if entry.layer.mark != Mark::Ohlc {
elements.push(Element::Polyline(Polyline {
points: vec![(x, y + 5.0), (x + LEGEND_LINE, y + 5.0)],
class: line_class(spec, entry),
series_index: None,
style_index: explicit.then_some(entry.global),
tooltip: None,
}));
}
let room = left + available_width - x - LEGEND_LINE - 8.0;
let path = format!("/panes/{}/layers/{}/name", entry.pane, entry.local);
let lines = match wrapped {
Some((first, second)) => vec![first, second],
None => vec![name],
};
for (index, line_text) in lines.iter().enumerate() {
let label = fit_text(line_text, room, LABEL_SIZE, metrics, warnings, &path);
elements.push(Element::Text(Text {
x: x + LEGEND_LINE + 8.0,
y: y + 9.0 + count(index) * LABEL_LINE,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content: label,
}));
}
}
}
fn bar_legend_entries(
dataset: &Dataset,
available_width: f64,
metrics: &impl TextMetrics,
) -> Vec<SeriesEntry> {
series_legend_entries(dataset, available_width, LEGEND_SWATCH, metrics)
}
struct SeriesEntry {
offset: f64,
row: usize,
lines: Vec<String>,
}
fn series_legend_entries(
dataset: &Dataset,
available_width: f64,
swatch: f64,
metrics: &impl TextMetrics,
) -> Vec<SeriesEntry> {
if dataset.series.len() < 2 {
return Vec::new();
}
let room = available_width - swatch;
let (mut x, mut row) = (0.0, 0);
let mut entries = Vec::new();
for series in &dataset.series {
let name = series
.name
.as_deref()
.expect("multi-series charts name every series");
let lines = match two_lines(name, room, LABEL_SIZE, metrics) {
Some((first, second)) => vec![first.to_owned(), second.to_owned()],
None => vec![name.to_owned()],
};
let width = metrics.width(name, LABEL_SIZE).min(room);
if x > 0.0 && (lines.len() > 1 || x + swatch + width > available_width) {
x = 0.0;
row += 1;
}
let rows = lines.len();
entries.push(SeriesEntry {
offset: x,
row,
lines,
});
if rows > 1 {
x = available_width;
row += rows - 1;
} else {
x += swatch + width + 20.0;
}
}
entries
}
fn series_legend_height(entries: &[SeriesEntry]) -> f64 {
entries.last().map_or(0.0, |entry| {
count(entry.row + entry.lines.len()) * LEGEND_HEIGHT
})
}
pub(super) fn legend_space(
dataset: &Dataset,
available_width: f64,
metrics: &impl TextMetrics,
) -> f64 {
series_legend_height(&bar_legend_entries(dataset, available_width, metrics))
}
pub(super) fn line_legend_space(
dataset: &Dataset,
available_width: f64,
metrics: &impl TextMetrics,
) -> f64 {
series_legend_height(&series_legend_entries(
dataset,
available_width,
LEGEND_LINE + 6.0,
metrics,
))
}
fn push_series_name(
elements: &mut Vec<Element>,
(x, y): (f64, f64),
lines: &[String],
room: f64,
index: usize,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
for (line, text) in lines.iter().enumerate() {
let label = fit_text(
text,
room,
LABEL_SIZE,
metrics,
warnings,
&format!("/series/{index}/name"),
);
elements.push(Element::Text(Text {
x,
y: y + 9.0 + count(line) * LABEL_LINE,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content: label,
}));
}
}
pub(super) fn add_line_legend(
dataset: &Dataset,
classes: &[&'static str],
plot: PlotArea,
head: f64,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let swatch = LEGEND_LINE + 6.0;
let (left, top, available_width) = (plot.left, LEGEND_ROW + head, plot.width);
let entries = series_legend_entries(dataset, available_width, swatch, metrics);
for (index, entry) in entries.iter().enumerate() {
let (x, y) = (left + entry.offset, top + count(entry.row) * LEGEND_HEIGHT);
elements.push(Element::Polyline(Polyline {
points: vec![(x, y + 5.0), (x + LEGEND_LINE, y + 5.0)],
class: classes[index],
series_index: None,
style_index: None,
tooltip: None,
}));
push_series_name(
elements,
(x + swatch, y),
&entry.lines,
available_width - swatch,
index,
warnings,
metrics,
);
}
}
pub(super) fn add_legend(
spec: &ChartSpec,
dataset: &Dataset,
plot: PlotArea,
head: f64,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
let (left, top, available_width) = (plot.left, LEGEND_ROW + head, plot.width);
let entries = bar_legend_entries(dataset, available_width, metrics);
for (index, entry) in entries.iter().enumerate() {
let (x, y) = (left + entry.offset, top + count(entry.row) * LEGEND_HEIGHT);
elements.push(Element::Rect(Rect {
x,
y,
width: 10.0,
height: 10.0,
class: series_bar_class(spec, index),
series_index: None,
style_index: None,
tooltip: None,
}));
push_series_name(
elements,
(x + LEGEND_SWATCH, y),
&entry.lines,
available_width - LEGEND_SWATCH,
index,
warnings,
metrics,
);
}
}