1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
use crate::core::aesthetics::{AestheticMapping, GlobalAesthetics};
use crate::core::utils::estimate_text_width;
use crate::scale::ScaleDomain;
use crate::scale::Tick;
use crate::scale::mapper::VisualMapper;
use crate::theme::Theme;
use std::collections::BTreeMap;
/// Represents the physical rectangular area required by a Guide (Legend or ColorBar).
/// Used by the LayoutEngine to reserve space and calculate the final Plot Panel.
#[derive(Debug, Clone, Copy, Default)]
pub struct GuideSize {
pub width: f64,
pub height: f64,
}
/// The visual representation strategy for a data field.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GuideKind {
/// A discrete list of symbols and labels. Used for categorical data,
/// or when multiple aesthetics (e.g., Color + Shape) are merged.
Legend,
/// A continuous gradient strip. Used exclusively for continuous Color mappings.
ColorBar,
}
/// Specification for a Guide (Legend or ColorBar), acting as the bridge
/// between abstract data scales and visual rendering instructions.
///
/// Following the "Grammar of Graphics" (like ggplot2), a single GuideSpec
/// consolidates multiple aesthetics (Color, Shape, Size) if they map to the same field.
pub struct GuideSpec {
/// The title displayed above the guide (usually the data field name).
pub title: String,
/// The data field name this guide represents (e.g., "mpg", "class").
pub field: String,
/// Determines if this is rendered as a discrete list or a gradient bar.
pub kind: GuideKind,
/// The data range and type (Categorical or Continuous).
pub domain: ScaleDomain,
/// The collection of visual mappings tied to this specific field.
pub mappings: Vec<AestheticMapping>,
}
impl GuideSpec {
/// Constructs a GuideSpec and performs **Semantic Inference**:
/// 1. If any mapping involves Size or Shape, it is forced to be a `Legend`.
/// 2. Only if it is strictly a continuous Color mapping does it become a `ColorBar`.
pub fn new(field: String, domain: ScaleDomain, mappings: Vec<AestheticMapping>) -> Self {
let mut has_complex_geometry = false;
let mut is_continuous_color = false;
for m in &mappings {
if let Some(mapper) = m.scale_impl.mapper() {
match mapper {
// Size and Shape require discrete symbol keys
VisualMapper::Size { .. } | VisualMapper::Shape { .. } => {
has_complex_geometry = true;
}
// Continuous color can potentially use a gradient bar
VisualMapper::ContinuousColor { .. } => {
is_continuous_color = true;
}
_ => {}
}
}
}
// If it involves symbols (Shape/Size) or mixed channels, we use Legend mode.
// ColorBar is reserved for pure continuous color mapping.
let kind = if is_continuous_color && !has_complex_geometry {
GuideKind::ColorBar
} else {
GuideKind::Legend
};
Self {
title: field.clone(),
field,
kind,
domain,
mappings,
}
}
/// Entry point for the LayoutEngine to calculate required pixels.
pub fn estimate_size(&self, theme: &Theme, max_h: f64) -> GuideSize {
match self.kind {
GuideKind::ColorBar => self.estimate_colorbar_size(theme, max_h),
GuideKind::Legend => self.estimate_legend_size(theme, max_h),
}
}
/// Estimates dimensions for a gradient ColorBar.
fn estimate_colorbar_size(&self, theme: &Theme, max_h: f64) -> GuideSize {
let font_size = theme.legend_label_size;
let title_font_size = font_size * 1.1;
let title_w = estimate_text_width(&self.title, title_font_size);
let bar_w = 15.0; // Standard thickness of the color strip
let labels = self.get_sampling_labels();
let max_lbl_w = labels
.iter()
.map(|l| estimate_text_width(l, font_size))
.fold(0.0, f64::max);
GuideSize {
width: f64::max(title_w, bar_w + theme.legend_marker_text_gap + max_lbl_w),
// Height is usually 70% of plot height or capped at a reasonable max (200px)
height: title_font_size + theme.legend_title_gap + f64::min(200.0, max_h * 0.7),
}
}
/// Estimates dimensions for a discrete Legend, supporting multi-column wrapping.
fn estimate_legend_size(&self, theme: &Theme, max_h: f64) -> GuideSize {
let font_size = theme.legend_label_size;
let title_font_size = font_size * 1.1;
let title_w = estimate_text_width(&self.title, title_font_size);
let title_h = title_font_size;
let labels = self.get_sampling_labels();
let max_lbl_w = labels
.iter()
.map(|l| estimate_text_width(l, font_size))
.fold(0.0, f64::max);
let mut total_w = 0.0;
let mut cur_col_w = 0.0;
let mut cur_col_h = 0.0;
let mut max_observed_h = 0.0;
// Content area is limited by the total plot height minus the title space
let content_limit = f64::max(max_h - title_h - theme.legend_title_gap, 20.0);
for (i, _) in labels.iter().enumerate() {
let marker_area_w = 18.0; // Reserved square for the icon/glyph
let row_h = f64::max(marker_area_w, font_size);
let row_w = marker_area_w + theme.legend_marker_text_gap + max_lbl_w;
// Column Wrapping Logic: Start a new column if the current one is full
if cur_col_h + row_h > content_limit && cur_col_h > 0.0 {
total_w += cur_col_w + theme.legend_col_h_gap;
max_observed_h = f64::max(max_observed_h, cur_col_h);
cur_col_h = row_h;
cur_col_w = row_w;
} else {
cur_col_h += row_h;
if i < labels.len() - 1 {
cur_col_h += theme.legend_item_v_gap;
}
cur_col_w = f64::max(cur_col_w, row_w);
}
}
total_w += cur_col_w;
max_observed_h = f64::max(max_observed_h, cur_col_h);
GuideSize {
width: f64::max(title_w, total_w),
height: title_h + theme.legend_title_gap + max_observed_h,
}
}
/// Extracts string labels from the underlying Scale implementation and
/// enforces uniform decimal precision for visual alignment.
///
/// This method ensures that all labels in a legend block share the same number
/// of decimal places, preventing jagged text alignment (e.g., ensuring "20.0"
/// isn't shortened to "20" when appearing alongside "16.3").
pub(crate) fn get_sampling_labels(&self) -> Vec<String> {
if let Some(first_mapping) = self.mappings.first() {
// 1. Define target density (e.g., we want 5 circles for Size)
let count = match self.kind {
GuideKind::ColorBar => 5,
GuideKind::Legend => {
if let ScaleDomain::Discrete(ref v) = self.domain {
v.len()
} else {
5
}
}
};
// 2. Retrieve raw ticks from the scale (Pretty algorithm or Sample_n)
let mut ticks = first_mapping.scale_impl.suggest_ticks(count);
// Fallback to force-sampling if the pretty algorithm returns insufficient points
if ticks.len() < 3 && !matches!(self.domain, ScaleDomain::Discrete(_)) {
ticks = first_mapping.scale_impl.sample_n(count);
}
// 3. --- Uniform Precision Logic ---
// Check if we are dealing with a numeric (non-categorical) scale
if !matches!(self.domain, ScaleDomain::Discrete(_)) {
// Determine the maximum precision needed across all sampled points.
// We look for the most specific decimal place to ensure no data is lost.
let mut max_precision = 0;
let has_fractions = ticks
.iter()
.any(|t| (t.value - t.value.floor()).abs() > 1e-9);
if has_fractions {
for tick in &ticks {
// Find how many decimals this specific number actually uses
let s = format!("{}", tick.value);
if let Some(pos) = s.find('.') {
let p = s.len() - pos - 1;
if p > max_precision {
max_precision = p;
}
}
}
// For aesthetics, we force at least 1 decimal if any fractions exist
max_precision = max_precision.clamp(1, 4);
}
// Re-format all ticks using the discovered global precision
ticks
.into_iter()
.map(|t| format!("{:.1$}", t.value, max_precision))
.collect()
} else {
// For categorical data, use labels exactly as provided by the scale
ticks.into_iter().map(|t| t.label).collect()
}
} else {
// Fallback for empty mappings
match &self.domain {
ScaleDomain::Discrete(v) => v.clone(),
_ => Vec::new(),
}
}
}
/// Returns the raw Tick objects (value + aligned label) used for sampling.
pub(crate) fn get_sampling_ticks(&self) -> Vec<Tick> {
if let Some(first_mapping) = self.mappings.first() {
let count = 5; // Target density
let mut ticks = first_mapping.scale_impl.suggest_ticks(count);
if ticks.len() < 3 && !matches!(self.domain, ScaleDomain::Discrete(_)) {
ticks = first_mapping.scale_impl.sample_n(count);
}
// Apply the precision alignment we discussed earlier
let mut max_p = 0;
let has_fractions = ticks
.iter()
.any(|t| (t.value - t.value.floor()).abs() > 1e-9);
if has_fractions {
for t in &ticks {
let s = format!("{}", t.value);
if let Some(pos) = s.find('.') {
max_p = max_p.max(s.len() - pos - 1);
}
}
max_p = max_p.clamp(1, 4);
}
// Update labels in the ticks themselves
for t in &mut ticks {
t.label = format!("{:.1$}", t.value, max_p);
}
ticks
} else {
Vec::new()
}
}
}
/// Core manager responsible for grouping aesthetics and generating GuideSpecs.
pub struct GuideManager;
impl GuideManager {
/// Orchestrates the collection of global aesthetics into a consolidated set of GuideSpecs.
///
/// This function implements the "Legend Merging" logic. According to the Grammar of Graphics,
/// if multiple aesthetics (e.g., Color, Shape, and Size) are mapped to the same data field,
/// they should be unified into a single visual guide (Legend) to avoid redundancy and
/// improve scannability.
///
/// # Logic Flow:
/// 1. Group all active `AestheticMapping` instances by their `field` name.
/// 2. Use a `BTreeMap` to ensure that guides are generated in a stable, alphabetical order.
/// 3. Pass the consolidated mappings to `GuideSpec::new`, which infers the visual
/// type (Legend vs. ColorBar) based on the combined mapping properties.
pub fn collect_guides(aesthetics: &GlobalAesthetics) -> Vec<GuideSpec> {
// We group mappings by field name. The tuple contains the inferred ScaleDomain
// and the list of mappings associated with that field.
let mut field_map: BTreeMap<String, (ScaleDomain, Vec<AestheticMapping>)> = BTreeMap::new();
// Helper closure to safely extract and group active mappings.
let mut collect = |mapping: &Option<AestheticMapping>| {
if let Some(m) = mapping {
let entry = field_map.entry(m.field.clone()).or_insert_with(|| {
// We capture the domain from the first mapping encountered for this field.
// In a valid plot, all aesthetics sharing a field should share the same scale logic.
(m.scale_impl.get_domain_enum(), Vec::new())
});
entry.1.push(m.clone());
}
};
// --- Phase 1: Aggregation ---
// Scan standard aesthetic channels. Order of collection doesn't affect the
// result because BTreeMap handles the final sorting.
collect(&aesthetics.color);
collect(&aesthetics.shape);
collect(&aesthetics.size);
// --- Phase 2: Specification ---
// Convert each field group into a high-level GuideSpec.
// The GuideSpec will later use the `sample_n` logic implemented in the scales
// to generate the 5 visual steps (circles/colors) you requested.
field_map
.into_iter()
.map(|(field, (domain, mappings))| {
// GuideSpec::new performs semantic inference to decide if this
// should be rendered as a discrete Legend or a continuous ColorBar.
GuideSpec::new(field, domain, mappings)
})
.collect()
}
}
/// Defines where the legend block is placed relative to the chart.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum LegendPosition {
Top,
Bottom,
Left,
#[default]
Right,
None,
}