Skip to main content

ferroui_livecharts/
cartesian_chart_engine.rs

1//! Cartesian measurement adapted from the pinned chart and series implementations.
2//! Pure calculation produces a retained FerroUI drawing scene; native controls own scheduling.
3
4use crate::axis::{format_number, Axis, AxisPosition, AxisScale};
5use crate::kernel::Coordinate;
6use crate::measure::{AxisOrientation, Bounds};
7use crate::scene::*;
8use crate::series::{Series, SeriesKind};
9use ferroui_base::{media::Color, Point, Rect, Size};
10use std::collections::BTreeMap;
11
12#[derive(Clone, Debug, PartialEq)]
13pub struct Section {
14    pub x_axis: usize,
15    pub y_axis: usize,
16    pub x_min: Option<f64>,
17    pub x_max: Option<f64>,
18    pub y_min: Option<f64>,
19    pub y_max: Option<f64>,
20    pub fill: Option<Color>,
21    pub stroke: Option<Color>,
22    pub stroke_width: f64,
23}
24impl Default for Section {
25    fn default() -> Self {
26        Self {
27            x_axis: 0,
28            y_axis: 0,
29            x_min: None,
30            x_max: None,
31            y_min: None,
32            y_max: None,
33            fill: Some(Color::from_argb(35, 66, 99, 235)),
34            stroke: None,
35            stroke_width: 1.0,
36        }
37    }
38}
39
40#[derive(Clone, Debug, PartialEq)]
41pub struct CartesianOptions {
42    /// Explicit control-local plot rectangle. None computes margins for visible axes.
43    pub draw_margin: Option<Rect>,
44    pub sections: Vec<Section>,
45}
46impl Default for CartesianOptions {
47    fn default() -> Self {
48        Self {
49            draw_margin: None,
50            sections: Vec::new(),
51        }
52    }
53}
54
55/// Calculated axes can also drive crosshairs and zoom/pan without reinterpreting pixels.
56#[derive(Clone, Debug, PartialEq)]
57pub struct CartesianMeasurement {
58    pub scene: Scene,
59    pub x_scales: Vec<AxisScale>,
60    pub y_scales: Vec<AxisScale>,
61    /// Per-series data extrema and source padding/geometry requests, in model order.
62    pub series_bounds: Vec<Option<CartesianSeriesBounds>>,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct CartesianSeriesBounds {
67    pub x: Bounds,
68    pub y: Bounds,
69}
70
71pub struct CartesianChartEngine;
72impl CartesianChartEngine {
73    pub fn measure(
74        size: Size,
75        series: &[Series],
76        x_axes: &[Axis],
77        y_axes: &[Axis],
78        options: &CartesianOptions,
79    ) -> Result<Scene, ChartError> {
80        Self::measure_with_axes(size, series, x_axes, y_axes, options).map(|m| m.scene)
81    }
82    pub fn measure_with_axes(
83        size: Size,
84        series: &[Series],
85        x_axes: &[Axis],
86        y_axes: &[Axis],
87        options: &CartesianOptions,
88    ) -> Result<CartesianMeasurement, ChartError> {
89        Self::measure_with_axes_and_labels(size, series, x_axes, y_axes, options, |text, size| {
90            Size::new(text.chars().count() as f64 * size * 0.6, size * 1.2)
91        })
92    }
93    pub fn measure_with_axes_and_labels(
94        size: Size,
95        series: &[Series],
96        x_axes: &[Axis],
97        y_axes: &[Axis],
98        options: &CartesianOptions,
99        mut measure_text: impl FnMut(&str, f64) -> Size,
100    ) -> Result<CartesianMeasurement, ChartError> {
101        validate_size(size)?;
102        let defaults = [Axis::default()];
103        let x_axes = if x_axes.is_empty() {
104            &defaults[..]
105        } else {
106            x_axes
107        };
108        let y_axes = if y_axes.is_empty() {
109            &defaults[..]
110        } else {
111            y_axes
112        };
113        let plot = plot_bounds(size, x_axes, y_axes, options.draw_margin)?;
114        let mut scene = Scene::new(size, plot);
115        // Kernel.Extensions.GetFindingStrategy chooses one policy for all visible
116        // series: category X, category Y, or two-dimensional comparison.
117        let mut all_x = true;
118        let mut all_y = true;
119        for s in series.iter().filter(|s| s.visible) {
120            all_x &= !s.kind.is_row() && !matches!(s.kind, SeriesKind::Scatter | SeriesKind::Heat);
121            all_y &= s.kind.is_row();
122        }
123        scene.automatic_finding_strategy = if all_x {
124            FindingStrategy::CompareOnlyXTakeClosest
125        } else if all_y {
126            FindingStrategy::CompareOnlyYTakeClosest
127        } else {
128            FindingStrategy::CompareAllTakeClosest
129        };
130        let mut x_bounds = vec![(f64::INFINITY, f64::NEG_INFINITY); x_axes.len()];
131        let mut y_bounds = vec![(f64::INFINITY, f64::NEG_INFINITY); y_axes.len()];
132        let mut totals: BTreeMap<StackKey, (f64, f64)> = BTreeMap::new();
133        for (si, s) in series.iter().enumerate().filter(|(_, s)| s.visible) {
134            validate_series(s, x_axes.len(), y_axes.len())?;
135            scene.legend.push(LegendItem {
136                series: si,
137                name: if s.name.is_empty() {
138                    format!("Series {}", si + 1)
139                } else {
140                    s.name.clone()
141                },
142                color: s.color(si),
143            });
144            if s.kind.is_stacked() && s.stack_100_percent {
145                for c in s.values.iter().filter(|c| valid_point(s, c)) {
146                    let t = totals.entry(stack_key(s, c)).or_default();
147                    if c.primary_value >= 0.0 {
148                        t.0 += c.primary_value;
149                    } else {
150                        t.1 -= c.primary_value;
151                    }
152                }
153            }
154        }
155        let mut stacks: BTreeMap<StackKey, (f64, f64)> = BTreeMap::new();
156        let mut measured: Vec<Vec<Option<MeasuredPoint>>> = Vec::with_capacity(series.len());
157        let mut series_bounds = Vec::with_capacity(series.len());
158        for s in series {
159            let mut points = Vec::with_capacity(s.values.len());
160            if !s.visible {
161                measured.push(points);
162                series_bounds.push(None);
163                continue;
164            }
165            let mut own_x = (f64::INFINITY, f64::NEG_INFINITY);
166            let mut own_y = (f64::INFINITY, f64::NEG_INFINITY);
167            let category_half = if s.kind.is_bar() {
168                if s.kind.is_row() {
169                    y_axes[s.y_axis].unit_width
170                } else {
171                    x_axes[s.x_axis].unit_width
172                }
173                .abs()
174                    * 0.5
175            } else {
176                0.0
177            };
178            for (index, c) in s.values.iter().copied().enumerate() {
179                if !valid_point(s, &c) {
180                    points.push(None);
181                    continue;
182                }
183                let mut top = c.primary_value;
184                let mut bottom = match s.kind {
185                    SeriesKind::RangeColumn | SeriesKind::RangeRow | SeriesKind::RangeLine => {
186                        c.tertiary_value
187                    }
188                    SeriesKind::Financial | SeriesKind::Box => c.quinary_value,
189                    _ => s.pivot,
190                };
191                if s.kind.is_stacked() {
192                    let key = stack_key(s, &c);
193                    if s.stack_100_percent {
194                        let total = totals.get(&key).copied().unwrap_or_default();
195                        let denominator = if top >= 0.0 { total.0 } else { total.1 };
196                        top = if denominator == 0.0 {
197                            0.0
198                        } else {
199                            top / denominator * 100.0
200                        };
201                    }
202                    let stack = stacks.entry(key).or_insert((s.pivot, s.pivot));
203                    let offset = if top >= 0.0 {
204                        &mut stack.0
205                    } else {
206                        &mut stack.1
207                    };
208                    bottom = *offset;
209                    *offset += top;
210                    top = *offset;
211                }
212                let (mut xmin, mut xmax, mut ymin, mut ymax) = if s.kind.is_row() {
213                    (
214                        top,
215                        top,
216                        c.secondary_value - category_half,
217                        c.secondary_value + category_half,
218                    )
219                } else {
220                    (
221                        c.secondary_value - category_half,
222                        c.secondary_value + category_half,
223                        top,
224                        top,
225                    )
226                };
227                if s.kind.is_bar() || s.kind.is_area() || s.fill.is_some() || s.fill_brush.is_some()
228                {
229                    if s.kind.is_row() {
230                        xmin = xmin.min(bottom);
231                        xmax = xmax.max(bottom);
232                    } else {
233                        ymin = ymin.min(bottom);
234                        ymax = ymax.max(bottom);
235                    }
236                }
237                if !c.point_error.is_empty {
238                    let (cx, cy) = if s.kind.is_row() {
239                        (top, c.secondary_value)
240                    } else {
241                        (c.secondary_value, top)
242                    };
243                    xmin = xmin.min(cx - c.point_error.xi.abs());
244                    xmax = xmax.max(cx + c.point_error.xj.abs());
245                    ymin = ymin.min(cy - c.point_error.yj.abs());
246                    ymax = ymax.max(cy + c.point_error.yi.abs());
247                }
248                for value in [
249                    xmin,
250                    xmax,
251                    if s.kind.is_row() {
252                        top
253                    } else {
254                        c.secondary_value
255                    },
256                ] {
257                    include_axis(&mut x_bounds[s.x_axis], value, &x_axes[s.x_axis]);
258                    include_axis(&mut own_x, value, &x_axes[s.x_axis]);
259                }
260                for value in [
261                    ymin,
262                    ymax,
263                    if s.kind.is_row() {
264                        c.secondary_value
265                    } else {
266                        top
267                    },
268                ] {
269                    include_axis(&mut y_bounds[s.y_axis], value, &y_axes[s.y_axis]);
270                    include_axis(&mut own_y, value, &y_axes[s.y_axis]);
271                }
272                points.push(Some(MeasuredPoint {
273                    index,
274                    coordinate: c,
275                    bottom,
276                    top,
277                }));
278            }
279            series_bounds.push(if own_x.0.is_finite() && own_y.0.is_finite() {
280                Some(CartesianSeriesBounds {
281                    x: data_bounds(own_x),
282                    y: data_bounds(own_y),
283                })
284            } else {
285                None
286            });
287            measured.push(points);
288        }
289        // Heat cells represent a data interval, not a pixel sized glyph. Infer irregular
290        // date/number spacing rather than assuming the default axis unit of one.
291        for (si, s) in series
292            .iter()
293            .enumerate()
294            .filter(|(_, s)| s.visible && s.kind == SeriesKind::Heat)
295        {
296            let dx = minimum_delta(
297                s.values
298                    .iter()
299                    .filter(|c| valid_point(s, c))
300                    .map(|c| c.secondary_value),
301            )
302            .unwrap_or(x_axes[s.x_axis].unit_width.abs());
303            let dy = minimum_delta(
304                s.values
305                    .iter()
306                    .filter(|c| valid_point(s, c))
307                    .map(|c| c.primary_value),
308            )
309            .unwrap_or(y_axes[s.y_axis].unit_width.abs());
310            // Expand this series' cells, not the aggregate bounds. Otherwise two
311            // heat series on the same axes add the padding twice, and a distant
312            // line series is padded by unrelated heat-cell dimensions.
313            for c in s.values.iter().filter(|c| valid_point(s, c)) {
314                for value in [c.secondary_value - dx * 0.5, c.secondary_value + dx * 0.5] {
315                    include_axis(&mut x_bounds[s.x_axis], value, &x_axes[s.x_axis]);
316                    if value.is_finite()
317                        && (x_axes[s.x_axis].logarithmic_base.is_none() || value > 0.0)
318                    {
319                        if let Some(bounds) = &mut series_bounds[si] {
320                            bounds.x.append_value(value);
321                        }
322                    }
323                }
324                for value in [c.primary_value - dy * 0.5, c.primary_value + dy * 0.5] {
325                    include_axis(&mut y_bounds[s.y_axis], value, &y_axes[s.y_axis]);
326                    if value.is_finite()
327                        && (y_axes[s.y_axis].logarithmic_base.is_none() || value > 0.0)
328                    {
329                        if let Some(bounds) = &mut series_bounds[si] {
330                            bounds.y.append_value(value);
331                        }
332                    }
333                }
334            }
335        }
336        let x_label_sizes = x_axes
337            .iter()
338            .zip(&x_bounds)
339            .map(|(axis, bounds)| maximum_label_size(axis, *bounds, &mut measure_text))
340            .collect::<Vec<_>>();
341        let y_label_sizes = y_axes
342            .iter()
343            .zip(&y_bounds)
344            .map(|(axis, bounds)| maximum_label_size(axis, *bounds, &mut measure_text))
345            .collect::<Vec<_>>();
346        let mut x_requests = vec![Bounds::default(); x_axes.len()];
347        let mut y_requests = vec![Bounds::default(); y_axes.len()];
348        for (si, s) in series.iter().enumerate().filter(|(_, s)| s.visible) {
349            let Some(bounds) = &mut series_bounds[si] else {
350                continue;
351            };
352            let geometry_size = if matches!(
353                s.kind,
354                SeriesKind::Line
355                    | SeriesKind::StepLine
356                    | SeriesKind::Area
357                    | SeriesKind::StackedArea
358                    | SeriesKind::StackedStepArea
359                    | SeriesKind::RangeLine
360            ) {
361                (s.geometry_size.max(0.0)
362                    + if s.geometry_size > 0.0 {
363                        s.stroke_width.max(0.0)
364                    } else {
365                        0.0
366                    })
367                    * 0.5
368            } else {
369                0.0
370            };
371            set_padding_request(
372                &mut bounds.x,
373                &x_axes[s.x_axis],
374                size,
375                AxisOrientation::X,
376                x_label_sizes[s.x_axis],
377                s.data_padding.x,
378                geometry_size,
379            )?;
380            set_padding_request(
381                &mut bounds.y,
382                &y_axes[s.y_axis],
383                size,
384                AxisOrientation::Y,
385                y_label_sizes[s.y_axis],
386                s.data_padding.y,
387                geometry_size,
388            )?;
389            bounds.x.min_delta = minimum_delta(measured[si].iter().flatten().map(|p| {
390                if s.kind.is_row() {
391                    p.top
392                } else {
393                    p.coordinate.secondary_value
394                }
395            }))
396            .unwrap_or(f64::MAX);
397            bounds.y.min_delta = minimum_delta(measured[si].iter().flatten().map(|p| {
398                if s.kind.is_row() {
399                    p.coordinate.secondary_value
400                } else {
401                    p.top
402                }
403            }))
404            .unwrap_or(f64::MAX);
405            x_requests[s.x_axis].append_bounds(&bounds.x);
406            y_requests[s.y_axis].append_bounds(&bounds.y);
407        }
408        let all_columns = series
409            .iter()
410            .filter(|s| s.visible)
411            .all(|s| matches!(s.kind, SeriesKind::Column | SeriesKind::StackedColumn));
412        let x_scales = x_axes
413            .iter()
414            .zip(x_bounds.iter())
415            .zip(&x_requests)
416            .map(|((a, &(min, max)), request)| {
417                padded_scale(a, min, max, plot.x, plot.x + plot.width, request, false)
418            })
419            .collect::<Result<Vec<_>, _>>()?;
420        let y_scales = y_axes
421            .iter()
422            .zip(y_bounds.iter())
423            .zip(&y_requests)
424            .map(|((a, &(min, max)), request)| {
425                padded_scale(
426                    a,
427                    min,
428                    max,
429                    plot.y + plot.height,
430                    plot.y,
431                    request,
432                    all_columns,
433                )
434            })
435            .collect::<Result<Vec<_>, _>>()?;
436        draw_sections(&mut scene, &options.sections, &x_scales, &y_scales)?;
437        draw_axes(
438            &mut scene,
439            x_axes,
440            y_axes,
441            &x_scales,
442            &y_scales,
443            &x_label_sizes,
444            &y_label_sizes,
445        );
446        for (si, s) in series.iter().enumerate().filter(|(_, s)| s.visible) {
447            let xs = x_scales[s.x_axis];
448            let ys = y_scales[s.y_axis];
449            let points = &measured[si];
450            match s.kind {
451                SeriesKind::Line
452                | SeriesKind::StepLine
453                | SeriesKind::Area
454                | SeriesKind::StackedArea
455                | SeriesKind::StackedStepArea
456                | SeriesKind::RangeLine => draw_line_series(
457                    &mut scene,
458                    si,
459                    s,
460                    points,
461                    xs,
462                    ys,
463                    x_axes[s.x_axis].unit_width,
464                ),
465                SeriesKind::Column
466                | SeriesKind::Row
467                | SeriesKind::StackedColumn
468                | SeriesKind::StackedRow
469                | SeriesKind::RangeColumn
470                | SeriesKind::RangeRow => {
471                    draw_bars(&mut scene, si, s, series, points, xs, ys, x_axes, y_axes)
472                }
473                SeriesKind::Scatter => draw_scatter(&mut scene, si, s, points, xs, ys),
474                SeriesKind::Heat => draw_heat(&mut scene, si, s, points, xs, ys, x_axes, y_axes),
475                SeriesKind::Financial | SeriesKind::Box => {
476                    draw_financial(&mut scene, si, s, points, xs, ys, x_axes)
477                }
478                SeriesKind::Pie | SeriesKind::PolarLine => {
479                    return Err(ChartError::InvalidData(
480                        "a pie or polar series requires its matching chart engine".into(),
481                    ));
482                }
483            }
484        }
485        Ok(CartesianMeasurement {
486            scene,
487            x_scales,
488            y_scales,
489            series_bounds,
490        })
491    }
492}
493
494fn data_bounds((min, max): (f64, f64)) -> Bounds {
495    Bounds {
496        min,
497        max,
498        is_empty: false,
499        ..Bounds::default()
500    }
501}
502fn maximum_label_size(
503    axis: &Axis,
504    bounds: (f64, f64),
505    measure: &mut impl FnMut(&str, f64) -> Size,
506) -> Size {
507    let min = axis
508        .min_limit
509        .unwrap_or(if bounds.0.is_finite() { bounds.0 } else { 0.0 });
510    let max = axis
511        .max_limit
512        .unwrap_or(if bounds.1.is_finite() { bounds.1 } else { 10.0 });
513    let mut maximum = Size::new(0.0, 0.0);
514    // Source CoreAxis.GetPossibleMaxLabelSize samples 25 intervals before ticks.
515    for i in 0..=25 {
516        let fraction = i as f64 / 25.0;
517        let value = min * (1.0 - fraction) + max * fraction;
518        let measured = measure(&axis.label(value), axis.text_size);
519        if measured.width.is_finite() && measured.height.is_finite() {
520            maximum.width = maximum
521                .width
522                .max(measured.width + axis.labels_padding * 2.0);
523            maximum.height = maximum
524                .height
525                .max(measured.height + axis.labels_padding * 2.0);
526        }
527    }
528    maximum
529}
530fn set_padding_request(
531    bounds: &mut Bounds,
532    axis: &Axis,
533    size: Size,
534    orientation: AxisOrientation,
535    labels: Size,
536    padding: f64,
537    geometry: f64,
538) -> Result<(), ChartError> {
539    let mut axis = axis.clone();
540    axis.padding = 0.0;
541    let scale = AxisScale::new(&axis, bounds.min, bounds.max, 0.0, 1.0)?;
542    let request = scale
543        .tick_with_metrics(&axis, size, orientation, labels)
544        .value
545        * padding;
546    bounds.padding_min = if request.is_finite() {
547        request.max(0.0)
548    } else {
549        0.0
550    };
551    bounds.padding_max = bounds.padding_min;
552    bounds.requested_geometry_size = geometry;
553    Ok(())
554}
555fn padded_scale(
556    axis: &Axis,
557    raw_min: f64,
558    raw_max: f64,
559    start: f64,
560    end: f64,
561    request: &Bounds,
562    columns: bool,
563) -> Result<AxisScale, ChartError> {
564    let mut plain = axis.clone();
565    plain.padding = 0.0;
566    let initial = AxisScale::new(&plain, raw_min, raw_max, start, end)?;
567    if axis.logarithmic_base.is_some() {
568        return Ok(initial);
569    }
570    let (mut min, mut max) = (initial.min, initial.max);
571    let percent = ((max - min) * axis.padding.max(0.0)).max(0.0);
572    let percent = if percent.is_finite() { percent } else { 0.0 };
573    let geometry_padding = |scale: AxisScale| {
574        if start == end {
575            return 0.0;
576        }
577        let value = (scale.to_chart_values(start + request.requested_geometry_size)
578            - scale.to_chart_values(start))
579        .abs();
580        if value.is_finite() {
581            value
582        } else {
583            0.0
584        }
585    };
586    if axis.min_limit.is_none() {
587        let amount = geometry_padding(initial)
588            .max(request.padding_min)
589            .max(percent);
590        if (min - amount).is_finite() {
591            min -= amount;
592        }
593        if columns && raw_min >= 0.0 {
594            min = min.max(0.0);
595        }
596    }
597    if axis.max_limit.is_none() {
598        // The source re-creates its scaler after applying the lower padding.
599        let after_min = AxisScale::new(&plain, min, max, start, end)?;
600        let amount = geometry_padding(after_min)
601            .max(request.padding_max)
602            .max(percent);
603        if (max + amount).is_finite() {
604            max += amount;
605        }
606        if columns && raw_max <= 0.0 {
607            max = max.min(0.0);
608        }
609    }
610    AxisScale::new(&plain, min, max, start, end)
611}
612
613#[derive(Clone, Copy)]
614struct MeasuredPoint {
615    index: usize,
616    coordinate: Coordinate,
617    bottom: f64,
618    top: f64,
619}
620type StackKey = (usize, usize, u8, u32, u64);
621fn stack_key(s: &Series, c: &Coordinate) -> StackKey {
622    (
623        s.x_axis,
624        s.y_axis,
625        if s.kind.is_row() {
626            1
627        } else if s.kind.is_bar() {
628            2
629        } else {
630            3
631        },
632        s.stack_group,
633        if c.secondary_value == 0.0 {
634            0
635        } else {
636            c.secondary_value.to_bits()
637        },
638    )
639}
640fn include_axis(bounds: &mut (f64, f64), value: f64, axis: &Axis) {
641    if value.is_finite() && (axis.logarithmic_base.is_none() || value > 0.0) {
642        bounds.0 = bounds.0.min(value);
643        bounds.1 = bounds.1.max(value);
644    }
645}
646fn baseline(scale: AxisScale, value: f64) -> f64 {
647    scale.to_pixels(if scale.logarithmic_base.is_some() && value <= 0.0 {
648        scale.min
649    } else {
650        value
651    })
652}
653fn valid_point(s: &Series, c: &Coordinate) -> bool {
654    if c.is_empty || !c.primary_value.is_finite() || !c.secondary_value.is_finite() {
655        return false;
656    }
657    match s.kind {
658        SeriesKind::RangeColumn
659        | SeriesKind::RangeRow
660        | SeriesKind::RangeLine
661        | SeriesKind::Scatter
662        | SeriesKind::Heat => c.tertiary_value.is_finite(),
663        SeriesKind::Financial => {
664            c.tertiary_value.is_finite()
665                && c.quaternary_value.is_finite()
666                && c.quinary_value.is_finite()
667        }
668        SeriesKind::Box => {
669            c.tertiary_value.is_finite()
670                && c.quaternary_value.is_finite()
671                && c.quinary_value.is_finite()
672                && c.senary_value.is_finite()
673        }
674        _ => true,
675    }
676}
677fn validate_series(s: &Series, x: usize, y: usize) -> Result<(), ChartError> {
678    if s.x_axis >= x || s.y_axis >= y {
679        return Err(ChartError::InvalidAxis(
680            "series refers to a missing axis".into(),
681        ));
682    }
683    if [
684        s.pivot,
685        s.geometry_size,
686        s.min_geometry_size,
687        s.line_smoothness,
688        s.stroke_width,
689        s.opacity,
690        s.padding,
691        s.corner_radius,
692        s.max_bar_width,
693        s.data_labels_size,
694        s.data_padding.x,
695        s.data_padding.y,
696    ]
697    .iter()
698    .any(|v| !v.is_finite())
699        || s.dashes.iter().any(|d| !d.is_finite() || *d < 0.0)
700        || s.data_padding.x < 0.0
701        || s.data_padding.y < 0.0
702    {
703        return Err(ChartError::InvalidData(
704            "series dimensions and paint values must be finite".into(),
705        ));
706    }
707    Ok(())
708}
709fn plot_bounds(
710    size: Size,
711    x: &[Axis],
712    y: &[Axis],
713    explicit: Option<Rect>,
714) -> Result<Rect, ChartError> {
715    if let Some(r) = explicit {
716        if [r.x, r.y, r.width, r.height].iter().any(|v| !v.is_finite())
717            || r.width < 0.0
718            || r.height < 0.0
719        {
720            return Err(ChartError::InvalidData(
721                "draw margin must be finite with nonnegative size".into(),
722            ));
723        }
724        return Ok(r);
725    }
726    let left = y
727        .iter()
728        .filter(|a| a.visible && a.position == AxisPosition::Start)
729        .count() as f64
730        * 58.0
731        + 8.0;
732    let right = y
733        .iter()
734        .filter(|a| a.visible && a.position == AxisPosition::End)
735        .count() as f64
736        * 58.0
737        + 12.0;
738    let top = x
739        .iter()
740        .filter(|a| a.visible && a.position == AxisPosition::End)
741        .count() as f64
742        * 40.0
743        + 12.0;
744    let bottom = x
745        .iter()
746        .filter(|a| a.visible && a.position == AxisPosition::Start)
747        .count() as f64
748        * 40.0
749        + 8.0;
750    let sx = (size.width / (left + right).max(1.0)).min(1.0);
751    let sy = (size.height / (top + bottom).max(1.0)).min(1.0);
752    Ok(Rect::new(
753        left * sx,
754        top * sy,
755        (size.width - (left + right) * sx).max(0.0),
756        (size.height - (top + bottom) * sy).max(0.0),
757    ))
758}
759fn draw_axes(
760    scene: &mut Scene,
761    x: &[Axis],
762    y: &[Axis],
763    xs: &[AxisScale],
764    ys: &[AxisScale],
765    x_label_sizes: &[Size],
766    y_label_sizes: &[Size],
767) {
768    let r = scene.plot_bounds;
769    let mut x_offsets = [0.0, 0.0];
770    let mut y_offsets = [0.0, 0.0];
771    for ((axis, scale), labels) in x
772        .iter()
773        .zip(xs)
774        .zip(x_label_sizes)
775        .filter(|((a, _), _)| a.visible)
776    {
777        let side = usize::from(axis.position == AxisPosition::End);
778        let baseline = if side == 0 {
779            r.y + r.height + x_offsets[side]
780        } else {
781            r.y - x_offsets[side]
782        };
783        for value in scale.ticks_with_metrics(axis, scene.size, AxisOrientation::X, *labels) {
784            let px = scale.to_pixels(value);
785            if !px.is_finite() {
786                continue;
787            }
788            if axis.show_separator_lines {
789                scene.primitives.push(
790                    Primitive::new(
791                        Geometry::Line {
792                            start: Point::new(px, r.y),
793                            end: Point::new(px, r.y + r.height),
794                        },
795                        PaintStyle::stroke(axis.separators_color, 1.0),
796                    )
797                    .clipped(r),
798                );
799            }
800            scene.primitives.push(Primitive::new(
801                Geometry::Text {
802                    origin: Point::new(
803                        px,
804                        if side == 0 {
805                            baseline + 6.0
806                        } else {
807                            baseline - axis.text_size - 6.0
808                        },
809                    ),
810                    text: axis.label(value),
811                    size: axis.text_size,
812                    rotation: axis.labels_rotation,
813                    anchor: TextAnchor::Center,
814                },
815                PaintStyle::fill(axis.labels_color),
816            ));
817        }
818        if !axis.name.is_empty() {
819            scene.primitives.push(Primitive::new(
820                Geometry::Text {
821                    origin: Point::new(
822                        r.x + r.width * 0.5,
823                        if side == 0 {
824                            baseline + 23.0
825                        } else {
826                            baseline - 30.0
827                        },
828                    ),
829                    text: axis.name.clone(),
830                    size: axis.text_size,
831                    rotation: 0.0,
832                    anchor: TextAnchor::Center,
833                },
834                PaintStyle::fill(axis.labels_color),
835            ));
836        }
837        x_offsets[side] += 40.0;
838    }
839    for ((axis, scale), labels) in y
840        .iter()
841        .zip(ys)
842        .zip(y_label_sizes)
843        .filter(|((a, _), _)| a.visible)
844    {
845        let side = usize::from(axis.position == AxisPosition::End);
846        let baseline = if side == 0 {
847            r.x - y_offsets[side]
848        } else {
849            r.x + r.width + y_offsets[side]
850        };
851        for value in scale.ticks_with_metrics(axis, scene.size, AxisOrientation::Y, *labels) {
852            let py = scale.to_pixels(value);
853            if !py.is_finite() {
854                continue;
855            }
856            if axis.show_separator_lines {
857                scene.primitives.push(
858                    Primitive::new(
859                        Geometry::Line {
860                            start: Point::new(r.x, py),
861                            end: Point::new(r.x + r.width, py),
862                        },
863                        PaintStyle::stroke(axis.separators_color, 1.0),
864                    )
865                    .clipped(r),
866                );
867            }
868            scene.primitives.push(Primitive::new(
869                Geometry::Text {
870                    origin: Point::new(
871                        if side == 0 {
872                            baseline - 8.0
873                        } else {
874                            baseline + 8.0
875                        },
876                        py - axis.text_size * 0.5,
877                    ),
878                    text: axis.label(value),
879                    size: axis.text_size,
880                    rotation: axis.labels_rotation,
881                    anchor: if side == 0 {
882                        TextAnchor::End
883                    } else {
884                        TextAnchor::Start
885                    },
886                },
887                PaintStyle::fill(axis.labels_color),
888            ));
889        }
890        if !axis.name.is_empty() {
891            scene.primitives.push(Primitive::new(
892                Geometry::Text {
893                    origin: Point::new(
894                        if side == 0 {
895                            baseline - 55.0
896                        } else {
897                            baseline + 48.0
898                        },
899                        r.y + r.height * 0.5,
900                    ),
901                    text: axis.name.clone(),
902                    size: axis.text_size,
903                    rotation: -90.0,
904                    anchor: TextAnchor::Center,
905                },
906                PaintStyle::fill(axis.labels_color),
907            ));
908        }
909        y_offsets[side] += 58.0;
910    }
911}
912fn draw_sections(
913    scene: &mut Scene,
914    sections: &[Section],
915    xs: &[AxisScale],
916    ys: &[AxisScale],
917) -> Result<(), ChartError> {
918    let plot = scene.plot_bounds;
919    for section in sections {
920        let Some(x) = xs.get(section.x_axis) else {
921            return Err(ChartError::InvalidAxis(
922                "section refers to a missing X axis".into(),
923            ));
924        };
925        let Some(y) = ys.get(section.y_axis) else {
926            return Err(ChartError::InvalidAxis(
927                "section refers to a missing Y axis".into(),
928            ));
929        };
930        let left = section.x_min.map(|v| x.to_pixels(v)).unwrap_or(plot.x);
931        let right = section
932            .x_max
933            .map(|v| x.to_pixels(v))
934            .unwrap_or(plot.x + plot.width);
935        let top = section.y_max.map(|v| y.to_pixels(v)).unwrap_or(plot.y);
936        let bottom = section
937            .y_min
938            .map(|v| y.to_pixels(v))
939            .unwrap_or(plot.y + plot.height);
940        if [left, right, top, bottom, section.stroke_width]
941            .iter()
942            .any(|v| !v.is_finite())
943        {
944            return Err(ChartError::InvalidData(
945                "section endpoints must be finite and valid for their axes".into(),
946            ));
947        }
948        let style = PaintStyle {
949            fill: section.fill,
950            stroke: section.stroke,
951            stroke_width: section.stroke_width,
952            ..PaintStyle::default()
953        };
954        let geometry = if left == right || top == bottom {
955            Geometry::Line {
956                start: Point::new(left, top),
957                end: Point::new(right, bottom),
958            }
959        } else {
960            Geometry::Rectangle {
961                rect: Rect::new(
962                    left.min(right),
963                    top.min(bottom),
964                    (right - left).abs(),
965                    (bottom - top).abs(),
966                ),
967                radius: 0.0,
968            }
969        };
970        scene
971            .primitives
972            .push(Primitive::new(geometry, style).clipped(plot));
973    }
974    Ok(())
975}
976fn finite_point(p: Point) -> bool {
977    p.x.is_finite() && p.y.is_finite()
978}
979fn location(p: &MeasuredPoint, x: AxisScale, y: AxisScale) -> Point {
980    Point::new(
981        x.to_pixels(p.coordinate.secondary_value),
982        y.to_pixels(p.top),
983    )
984}
985fn append_hit(
986    scene: &mut Scene,
987    si: usize,
988    s: &Series,
989    p: &MeasuredPoint,
990    shape: HitShape,
991    center: Point,
992) {
993    let value = if s.kind == SeriesKind::Heat {
994        p.coordinate.tertiary_value
995    } else {
996        p.coordinate.primary_value
997    };
998    let text = match s.kind {
999        SeriesKind::RangeColumn | SeriesKind::RangeRow | SeriesKind::RangeLine => format!(
1000            "{} – {}",
1001            format_number(p.coordinate.tertiary_value),
1002            format_number(p.coordinate.primary_value)
1003        ),
1004        SeriesKind::Financial => format!(
1005            "O {} H {} L {} C {}",
1006            format_number(p.coordinate.tertiary_value),
1007            format_number(p.coordinate.primary_value),
1008            format_number(p.coordinate.quinary_value),
1009            format_number(p.coordinate.quaternary_value)
1010        ),
1011        SeriesKind::Box => format!(
1012            "Min {} Q1 {} Median {} Q3 {} Max {}",
1013            format_number(p.coordinate.quinary_value),
1014            format_number(p.coordinate.quaternary_value),
1015            format_number(p.coordinate.senary_value),
1016            format_number(p.coordinate.tertiary_value),
1017            format_number(p.coordinate.primary_value)
1018        ),
1019        _ => format_number(value),
1020    };
1021    scene.hits.push(HitRegion {
1022        id: PointId {
1023            series: si,
1024            point: p.index,
1025        },
1026        shape,
1027        center,
1028        value,
1029        label: format!(
1030            "{}{}{}",
1031            s.name,
1032            if s.name.is_empty() { "" } else { ": " },
1033            text
1034        ),
1035    });
1036    if s.data_labels {
1037        scene.primitives.push(
1038            Primitive::new(
1039                Geometry::Text {
1040                    origin: Point::new(center.x + 4.0, center.y - s.data_labels_size - 4.0),
1041                    text,
1042                    size: s.data_labels_size,
1043                    rotation: 0.0,
1044                    anchor: TextAnchor::Start,
1045                },
1046                PaintStyle::fill(s.data_labels_color.unwrap_or_else(|| s.color(si))),
1047            )
1048            .clipped(scene.plot_bounds)
1049            .identified(PointId {
1050                series: si,
1051                point: p.index,
1052            }),
1053        );
1054    }
1055}
1056
1057pub(crate) fn line_path(points: &[Point], smoothness: f64, step: bool) -> Vec<PathCommand> {
1058    if points.is_empty() {
1059        return Vec::new();
1060    }
1061    let mut path = Vec::with_capacity(points.len() * if step { 2 } else { 1 });
1062    path.push(PathCommand::MoveTo(points[0]));
1063    for i in 1..points.len() {
1064        let current = points[i - 1];
1065        let next = points[i];
1066        if step {
1067            path.push(PathCommand::LineTo(Point::new(next.x, current.y)));
1068            path.push(PathCommand::LineTo(next));
1069        } else if smoothness <= 0.0 {
1070            path.push(PathCommand::LineTo(next));
1071        } else {
1072            let previous = points[i.saturating_sub(2)];
1073            let after = points[(i + 1).min(points.len() - 1)];
1074            let curve = crate::kernel::line_spline_math::control_points(
1075                previous, current, next, after, smoothness,
1076            );
1077            path.push(PathCommand::CubicTo {
1078                control1: curve.control1(),
1079                control2: curve.control2(),
1080                end: curve.end(),
1081            });
1082        }
1083    }
1084    path
1085}
1086fn draw_line_series(
1087    scene: &mut Scene,
1088    si: usize,
1089    s: &Series,
1090    points: &[Option<MeasuredPoint>],
1091    x: AxisScale,
1092    y: AxisScale,
1093    unit_width: f64,
1094) {
1095    let mut run = Vec::new();
1096    let flush = |scene: &mut Scene, run: &mut Vec<&MeasuredPoint>| {
1097        if run.is_empty() {
1098            return;
1099        }
1100        let top: Vec<_> = run.iter().map(|p| location(p, x, y)).collect();
1101        let step = matches!(s.kind, SeriesKind::StepLine | SeriesKind::StackedStepArea);
1102        let smooth = s.line_smoothness.clamp(0.0, 1.0);
1103        let mut path = line_path(&top, smooth, step);
1104        if s.kind.is_area() || s.fill.is_some() || s.fill_brush.is_some() {
1105            let bottom: Vec<_> = run
1106                .iter()
1107                .rev()
1108                .map(|p| {
1109                    Point::new(
1110                        x.to_pixels(p.coordinate.secondary_value),
1111                        baseline(y, p.bottom),
1112                    )
1113                })
1114                .collect();
1115            if let Some(first) = bottom.first() {
1116                path.push(PathCommand::LineTo(*first));
1117            }
1118            path.extend(line_path(&bottom, smooth, step).into_iter().skip(1));
1119            path.push(PathCommand::Close);
1120            let mut style = s.style(si, true);
1121            if s.fill.is_none() && s.fill_brush.is_none() {
1122                style.opacity *= 0.35;
1123            }
1124            style.stroke = None;
1125            style.stroke_brush = None;
1126            scene.primitives.push(
1127                Primitive::new(Geometry::Path(path), style)
1128                    .clipped(scene.plot_bounds)
1129                    .identified(PointId {
1130                        series: si,
1131                        point: usize::MAX,
1132                    }),
1133            );
1134        }
1135        let mut style = s.style(si, false);
1136        style.fill = None;
1137        style.fill_brush = None;
1138        scene.primitives.push(
1139            Primitive::new(Geometry::Path(line_path(&top, smooth, step)), style.clone())
1140                .clipped(scene.plot_bounds)
1141                .identified(PointId {
1142                    series: si,
1143                    point: usize::MAX,
1144                }),
1145        );
1146        if s.kind == SeriesKind::RangeLine {
1147            let low: Vec<_> = run
1148                .iter()
1149                .map(|p| {
1150                    Point::new(
1151                        x.to_pixels(p.coordinate.secondary_value),
1152                        baseline(y, p.bottom),
1153                    )
1154                })
1155                .collect();
1156            scene.primitives.push(
1157                Primitive::new(Geometry::Path(line_path(&low, smooth, false)), style)
1158                    .clipped(scene.plot_bounds)
1159                    .identified(PointId {
1160                        series: si,
1161                        point: usize::MAX,
1162                    }),
1163            );
1164        }
1165        run.clear();
1166    };
1167    for point in points {
1168        let Some(p) = point else {
1169            if s.enable_null_splitting {
1170                flush(scene, &mut run);
1171            }
1172            continue;
1173        };
1174        let center = location(p, x, y);
1175        if !finite_point(center) {
1176            flush(scene, &mut run);
1177            continue;
1178        }
1179        run.push(p);
1180    }
1181    flush(scene, &mut run);
1182
1183    // Draw markers, error bars and labels over all fills and strokes. Emitting
1184    // them while building a run lets its subsequent area fill obscure the points.
1185    for p in points.iter().flatten() {
1186        let center = location(p, x, y);
1187        if !finite_point(center) {
1188            continue;
1189        }
1190        let low = Point::new(center.x, baseline(y, p.bottom));
1191        let range = s.kind == SeriesKind::RangeLine;
1192        if range && !finite_point(low) {
1193            continue;
1194        }
1195        let marker_centers = [center, low];
1196        for marker in &marker_centers[..if range { 2 } else { 1 }] {
1197            if s.geometry_size > 0.0 {
1198                scene.primitives.push(
1199                    Primitive::new(
1200                        Geometry::Ellipse(Rect::new(
1201                            marker.x - s.geometry_size / 2.0,
1202                            marker.y - s.geometry_size / 2.0,
1203                            s.geometry_size,
1204                            s.geometry_size,
1205                        )),
1206                        s.style(si, true),
1207                    )
1208                    .clipped(scene.plot_bounds)
1209                    .identified(PointId {
1210                        series: si,
1211                        point: p.index,
1212                    }),
1213                );
1214            }
1215        }
1216        let marker_radius = (s.geometry_size / 2.0).max(0.5);
1217        let marker_shape = |center| HitShape::Circle {
1218            center,
1219            radius: marker_radius,
1220        };
1221        let exact = if range {
1222            HitShape::Compound(vec![marker_shape(center), marker_shape(low)])
1223        } else {
1224            marker_shape(center)
1225        };
1226        let slot = category_width(x, p.coordinate.secondary_value, unit_width).max(1.0);
1227        let hover_bounds = if range {
1228            Rect::new(
1229                center.x - slot * 0.5,
1230                center.y.min(low.y),
1231                slot,
1232                (center.y - low.y).abs().max(1.0),
1233            )
1234        } else {
1235            Rect::new(
1236                center.x - slot * 0.5,
1237                center.y - marker_radius,
1238                slot,
1239                marker_radius * 2.0,
1240            )
1241        };
1242        append_hit(
1243            scene,
1244            si,
1245            s,
1246            p,
1247            HitShape::WithHoverArea {
1248                shape: Box::new(exact),
1249                bounds: hover_bounds,
1250            },
1251            if range {
1252                Point::new(center.x, (center.y + low.y) * 0.5)
1253            } else {
1254                center
1255            },
1256        );
1257        if !range {
1258            draw_errors(scene, si, s, p, x, y);
1259        }
1260    }
1261}
1262fn category_width(scale: AxisScale, category: f64, unit: f64) -> f64 {
1263    (scale.to_pixels(category + unit.abs() * 0.5) - scale.to_pixels(category - unit.abs() * 0.5))
1264        .abs()
1265}
1266fn bar_group(si: usize, s: &Series, all: &[Series]) -> (usize, usize) {
1267    let mut keys = Vec::new();
1268    let mut own = 0;
1269    for (i, candidate) in all.iter().enumerate().filter(|(_, a)| {
1270        a.visible
1271            && a.kind.is_bar()
1272            && !matches!(a.kind, SeriesKind::Financial | SeriesKind::Box)
1273            && a.kind.is_row() == s.kind.is_row()
1274            && a.x_axis == s.x_axis
1275            && a.y_axis == s.y_axis
1276    }) {
1277        let key = if candidate.kind.is_stacked() {
1278            (true, candidate.stack_group as usize)
1279        } else {
1280            (false, i)
1281        };
1282        let pos = if let Some(pos) = keys.iter().position(|k| *k == key) {
1283            pos
1284        } else {
1285            keys.push(key);
1286            keys.len() - 1
1287        };
1288        if i == si {
1289            own = pos;
1290        }
1291    }
1292    (
1293        if s.ignores_bar_position {
1294            1
1295        } else {
1296            keys.len().max(1)
1297        },
1298        own,
1299    )
1300}
1301fn bar_layout(
1302    s: &Series,
1303    scale: AxisScale,
1304    category: f64,
1305    unit: f64,
1306    group: (usize, usize),
1307) -> (f64, f64) {
1308    let (count, own) = group;
1309    let raw = category_width(scale, category, unit);
1310    let group_width = (raw - s.padding.max(0.0)).max(1.0);
1311    let slot = (group_width / count as f64).min(s.max_bar_width.max(1.0));
1312    let width = (slot - s.padding.max(0.0)).max(1.0);
1313    let offset = if s.ignores_bar_position {
1314        0.0
1315    } else {
1316        (own as f64 - (count as f64 - 1.0) * 0.5) * slot
1317    };
1318    (width, offset)
1319}
1320fn draw_bars(
1321    scene: &mut Scene,
1322    si: usize,
1323    s: &Series,
1324    all: &[Series],
1325    points: &[Option<MeasuredPoint>],
1326    x: AxisScale,
1327    y: AxisScale,
1328    xa: &[Axis],
1329    ya: &[Axis],
1330) {
1331    let group = bar_group(si, s, all);
1332    for p in points.iter().flatten() {
1333        let row = s.kind.is_row();
1334        let category = p.coordinate.secondary_value;
1335        let scale = if row { y } else { x };
1336        let unit = if row {
1337            ya[s.y_axis].unit_width
1338        } else {
1339            xa[s.x_axis].unit_width
1340        };
1341        let (width, offset) = bar_layout(s, scale, category, unit, group);
1342        let center_category = scale.to_pixels(category) + offset;
1343        let value_scale = if row { x } else { y };
1344        let a = baseline(value_scale, p.bottom);
1345        let b = value_scale.to_pixels(p.top);
1346        if ![width, center_category, a, b].iter().all(|v| v.is_finite()) {
1347            continue;
1348        }
1349        let rect = if row {
1350            Rect::new(
1351                a.min(b),
1352                center_category - width / 2.0,
1353                (b - a).abs(),
1354                width,
1355            )
1356        } else {
1357            Rect::new(
1358                center_category - width / 2.0,
1359                a.min(b),
1360                width,
1361                (b - a).abs(),
1362            )
1363        };
1364        scene.primitives.push(
1365            Primitive::new(
1366                Geometry::Rectangle {
1367                    rect,
1368                    radius: s.corner_radius.max(0.0),
1369                },
1370                s.style(si, true),
1371            )
1372            .clipped(scene.plot_bounds)
1373            .identified(PointId {
1374                series: si,
1375                point: p.index,
1376            }),
1377        );
1378        let center = if row {
1379            Point::new(b, center_category)
1380        } else {
1381            Point::new(center_category, b)
1382        };
1383        let slot = category_width(scale, category, unit) / group.0 as f64;
1384        let hover_bounds = if row {
1385            Rect::new(
1386                rect.x,
1387                center_category - slot * 0.5,
1388                rect.width.max(1.0),
1389                slot,
1390            )
1391        } else {
1392            Rect::new(
1393                center_category - slot * 0.5,
1394                rect.y,
1395                slot,
1396                rect.height.max(1.0),
1397            )
1398        };
1399        append_hit(
1400            scene,
1401            si,
1402            s,
1403            p,
1404            HitShape::WithHoverArea {
1405                shape: Box::new(HitShape::Rectangle(rect)),
1406                bounds: hover_bounds,
1407            },
1408            center,
1409        );
1410        draw_errors(scene, si, s, p, x, y);
1411    }
1412}
1413fn draw_scatter(
1414    scene: &mut Scene,
1415    si: usize,
1416    s: &Series,
1417    points: &[Option<MeasuredPoint>],
1418    x: AxisScale,
1419    y: AxisScale,
1420) {
1421    let (mut min, mut max) = (f64::INFINITY, f64::NEG_INFINITY);
1422    for p in points.iter().flatten() {
1423        min = min.min(p.coordinate.tertiary_value);
1424        max = max.max(p.coordinate.tertiary_value);
1425    }
1426    for p in points.iter().flatten() {
1427        let center = location(p, x, y);
1428        if !finite_point(center) {
1429            continue;
1430        }
1431        let t = if max == min {
1432            1.0
1433        } else {
1434            (p.coordinate.tertiary_value - min) / (max - min)
1435        };
1436        let size = (s.min_geometry_size + (s.geometry_size - s.min_geometry_size) * t).max(0.0);
1437        scene.primitives.push(
1438            Primitive::new(
1439                Geometry::Ellipse(Rect::new(
1440                    center.x - size * 0.5,
1441                    center.y - size * 0.5,
1442                    size,
1443                    size,
1444                )),
1445                s.style(si, true),
1446            )
1447            .clipped(scene.plot_bounds)
1448            .identified(PointId {
1449                series: si,
1450                point: p.index,
1451            }),
1452        );
1453        append_hit(
1454            scene,
1455            si,
1456            s,
1457            p,
1458            HitShape::Circle {
1459                center,
1460                radius: (size * 0.5).max(5.0),
1461            },
1462            center,
1463        );
1464        draw_errors(scene, si, s, p, x, y);
1465    }
1466}
1467fn draw_heat(
1468    scene: &mut Scene,
1469    si: usize,
1470    s: &Series,
1471    points: &[Option<MeasuredPoint>],
1472    x: AxisScale,
1473    y: AxisScale,
1474    xa: &[Axis],
1475    ya: &[Axis],
1476) {
1477    let dx = minimum_delta(
1478        points
1479            .iter()
1480            .flatten()
1481            .map(|p| p.coordinate.secondary_value),
1482    )
1483    .unwrap_or(xa[s.x_axis].unit_width);
1484    let dy = minimum_delta(points.iter().flatten().map(|p| p.coordinate.primary_value))
1485        .unwrap_or(ya[s.y_axis].unit_width);
1486    let (mut min, mut max) = (f64::INFINITY, f64::NEG_INFINITY);
1487    for p in points.iter().flatten() {
1488        min = min.min(p.coordinate.tertiary_value);
1489        max = max.max(p.coordinate.tertiary_value);
1490    }
1491    for p in points.iter().flatten() {
1492        let c = p.coordinate;
1493        let a = Point::new(
1494            x.to_pixels(c.secondary_value - dx / 2.0),
1495            y.to_pixels(c.primary_value - dy / 2.0),
1496        );
1497        let b = Point::new(
1498            x.to_pixels(c.secondary_value + dx / 2.0),
1499            y.to_pixels(c.primary_value + dy / 2.0),
1500        );
1501        if !finite_point(a) || !finite_point(b) {
1502            continue;
1503        }
1504        let rect = Rect::new(
1505            a.x.min(b.x),
1506            a.y.min(b.y),
1507            (b.x - a.x).abs(),
1508            (b.y - a.y).abs(),
1509        );
1510        let t = if max == min {
1511            0.0
1512        } else {
1513            (c.tertiary_value - min) / (max - min)
1514        };
1515        let color = heat_color(&s.heat_map, t);
1516        scene.primitives.push(
1517            Primitive::new(
1518                Geometry::Rectangle { rect, radius: 0.0 },
1519                PaintStyle {
1520                    opacity: s.opacity,
1521                    ..PaintStyle::fill(color)
1522                },
1523            )
1524            .clipped(scene.plot_bounds)
1525            .identified(PointId {
1526                series: si,
1527                point: p.index,
1528            }),
1529        );
1530        append_hit(
1531            scene,
1532            si,
1533            s,
1534            p,
1535            HitShape::Rectangle(rect),
1536            Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5),
1537        );
1538    }
1539}
1540pub(crate) fn heat_color(colors: &[Color], t: f64) -> Color {
1541    if colors.is_empty() {
1542        return Color::from_argb(0, 0, 0, 0);
1543    }
1544    if colors.len() == 1 {
1545        return colors[0];
1546    }
1547    let pos = t.clamp(0.0, 1.0) * (colors.len() - 1) as f64;
1548    let i = (pos.floor() as usize).min(colors.len() - 2);
1549    let f = (pos - i as f64).clamp(0.0, 1.0);
1550    let (a, b) = (colors[i], colors[i + 1]);
1551    let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * f) as u8;
1552    Color::from_argb(mix(a.a, b.a), mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b))
1553}
1554fn draw_financial(
1555    scene: &mut Scene,
1556    si: usize,
1557    s: &Series,
1558    points: &[Option<MeasuredPoint>],
1559    x: AxisScale,
1560    y: AxisScale,
1561    xa: &[Axis],
1562) {
1563    for p in points.iter().flatten() {
1564        let c = p.coordinate;
1565        let center = x.to_pixels(c.secondary_value);
1566        let high = y.to_pixels(c.primary_value);
1567        let low = y.to_pixels(c.quinary_value);
1568        let open = y.to_pixels(c.tertiary_value);
1569        let close = y.to_pixels(c.quaternary_value);
1570        let category_width = category_width(x, c.secondary_value, xa[s.x_axis].unit_width);
1571        let width = category_width.min(s.max_bar_width.max(1.0));
1572        if ![center, high, low, open, close, width]
1573            .iter()
1574            .all(|v| v.is_finite())
1575        {
1576            continue;
1577        }
1578        let mut style = s.style(si, true);
1579        if s.kind == SeriesKind::Financial && s.fill.is_none() && s.fill_brush.is_none() {
1580            let color = if c.quaternary_value >= c.tertiary_value {
1581                Color::from_rgb(22, 163, 74)
1582            } else {
1583                Color::from_rgb(220, 38, 38)
1584            };
1585            style.fill = Some(color);
1586            style.stroke = Some(color);
1587        }
1588        let line_style = PaintStyle {
1589            fill: None,
1590            fill_brush: None,
1591            ..style.clone()
1592        };
1593        scene.primitives.push(
1594            Primitive::new(
1595                Geometry::Line {
1596                    start: Point::new(center, high),
1597                    end: Point::new(center, low),
1598                },
1599                line_style.clone(),
1600            )
1601            .clipped(scene.plot_bounds)
1602            .identified(PointId {
1603                series: si,
1604                point: p.index,
1605            }),
1606        );
1607        let body = Rect::new(
1608            center - width / 2.0,
1609            open.min(close),
1610            width,
1611            (close - open).abs().max(1.0),
1612        );
1613        scene.primitives.push(
1614            Primitive::new(
1615                Geometry::Rectangle {
1616                    rect: body,
1617                    radius: s.corner_radius.max(0.0),
1618                },
1619                style,
1620            )
1621            .clipped(scene.plot_bounds)
1622            .identified(PointId {
1623                series: si,
1624                point: p.index,
1625            }),
1626        );
1627        if s.kind == SeriesKind::Box {
1628            for value in [high, low, y.to_pixels(c.senary_value)] {
1629                scene.primitives.push(
1630                    Primitive::new(
1631                        Geometry::Line {
1632                            start: Point::new(center - width / 2.0, value),
1633                            end: Point::new(center + width / 2.0, value),
1634                        },
1635                        line_style.clone(),
1636                    )
1637                    .clipped(scene.plot_bounds)
1638                    .identified(PointId {
1639                        series: si,
1640                        point: p.index,
1641                    }),
1642                );
1643            }
1644        }
1645        let hit = Rect::new(
1646            center - width / 2.0,
1647            high.min(low),
1648            width,
1649            (low - high).abs(),
1650        );
1651        append_hit(
1652            scene,
1653            si,
1654            s,
1655            p,
1656            HitShape::WithHoverArea {
1657                shape: Box::new(HitShape::Rectangle(hit)),
1658                bounds: Rect::new(
1659                    center - category_width * 0.5,
1660                    hit.y,
1661                    category_width,
1662                    hit.height.max(1.0),
1663                ),
1664            },
1665            Point::new(center, (high + low) * 0.5),
1666        );
1667    }
1668}
1669fn draw_errors(
1670    scene: &mut Scene,
1671    si: usize,
1672    s: &Series,
1673    p: &MeasuredPoint,
1674    x: AxisScale,
1675    y: AxisScale,
1676) {
1677    let c = p.coordinate;
1678    let error = c.point_error;
1679    if error.is_empty {
1680        return;
1681    }
1682    let (vx, vy) = if s.kind.is_row() {
1683        (p.top, c.secondary_value)
1684    } else {
1685        (c.secondary_value, p.top)
1686    };
1687    let center = Point::new(x.to_pixels(vx), y.to_pixels(vy));
1688    let cap = 4.0;
1689    let style = PaintStyle::stroke(s.color(si), s.stroke_width.max(1.0));
1690    let left = x.to_pixels(vx - error.xi.abs());
1691    let right = x.to_pixels(vx + error.xj.abs());
1692    let top = y.to_pixels(vy + error.yi.abs());
1693    let bottom = y.to_pixels(vy - error.yj.abs());
1694    if ![left, right, top, bottom].iter().all(|v| v.is_finite()) {
1695        return;
1696    }
1697    for (a, b) in [
1698        (Point::new(left, center.y), Point::new(right, center.y)),
1699        (
1700            Point::new(left, center.y - cap),
1701            Point::new(left, center.y + cap),
1702        ),
1703        (
1704            Point::new(right, center.y - cap),
1705            Point::new(right, center.y + cap),
1706        ),
1707        (Point::new(center.x, top), Point::new(center.x, bottom)),
1708        (
1709            Point::new(center.x - cap, top),
1710            Point::new(center.x + cap, top),
1711        ),
1712        (
1713            Point::new(center.x - cap, bottom),
1714            Point::new(center.x + cap, bottom),
1715        ),
1716    ] {
1717        scene.primitives.push(
1718            Primitive::new(Geometry::Line { start: a, end: b }, style.clone())
1719                .clipped(scene.plot_bounds)
1720                .identified(PointId {
1721                    series: si,
1722                    point: p.index,
1723                }),
1724        );
1725    }
1726}
1727fn minimum_delta(values: impl Iterator<Item = f64>) -> Option<f64> {
1728    let mut values: Vec<_> = values.filter(|v| v.is_finite()).collect();
1729    values.sort_by(f64::total_cmp);
1730    values.dedup();
1731    values
1732        .windows(2)
1733        .map(|w| w[1] - w[0])
1734        .filter(|v| v.is_finite() && *v > 0.0)
1735        .min_by(f64::total_cmp)
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740    use super::*;
1741    fn scene(series: &[Series]) -> Scene {
1742        CartesianChartEngine::measure(
1743            Size::new(640.0, 400.0),
1744            series,
1745            &[],
1746            &[],
1747            &CartesianOptions::default(),
1748        )
1749        .unwrap()
1750    }
1751    #[test]
1752    fn line_nulls_split_paths_and_keep_original_point_indices() {
1753        let mut s = Series::from_values(SeriesKind::Line, [1.0, 2.0, 3.0, 4.0]);
1754        s.values[1] = Coordinate::EMPTY;
1755        s.geometry_size = 0.0;
1756        let result = scene(&[s]);
1757        assert_eq!(
1758            result.hits.iter().map(|h| h.id.point).collect::<Vec<_>>(),
1759            vec![0, 2, 3]
1760        );
1761        assert_eq!(
1762            result
1763                .primitives
1764                .iter()
1765                .filter(|p| matches!(p.geometry, Geometry::Path(_)))
1766                .count(),
1767            2
1768        );
1769    }
1770    #[test]
1771    fn line_can_connect_across_nulls() {
1772        let mut s = Series::from_values(SeriesKind::Line, [1.0, 2.0, 3.0]);
1773        s.values[1] = Coordinate::EMPTY;
1774        s.enable_null_splitting = false;
1775        s.geometry_size = 0.0;
1776        assert_eq!(
1777            scene(&[s])
1778                .primitives
1779                .iter()
1780                .filter(|p| matches!(p.geometry, Geometry::Path(_)))
1781                .count(),
1782            1
1783        );
1784    }
1785    #[test]
1786    fn range_bounds_include_both_endpoints() {
1787        let mut s = Series::new(SeriesKind::RangeColumn);
1788        s.values = vec![Coordinate::range(0.0, 10.0, -50.0)];
1789        let m = CartesianChartEngine::measure_with_axes(
1790            Size::new(640.0, 400.0),
1791            &[s],
1792            &[],
1793            &[],
1794            &CartesianOptions::default(),
1795        )
1796        .unwrap();
1797        assert!(m.y_scales[0].min <= -50.0);
1798        assert!(m.y_scales[0].max >= 10.0);
1799    }
1800    #[test]
1801    fn stacked_positive_and_negative_bounds_are_independent() {
1802        let a = Series::from_values(SeriesKind::StackedColumn, [3.0, -2.0]);
1803        let b = Series::from_values(SeriesKind::StackedColumn, [4.0, -5.0]);
1804        let m = CartesianChartEngine::measure_with_axes(
1805            Size::new(640.0, 400.0),
1806            &[a, b],
1807            &[],
1808            &[],
1809            &CartesianOptions::default(),
1810        )
1811        .unwrap();
1812        assert!(m.y_scales[0].min <= -7.0);
1813        assert!(m.y_scales[0].max >= 7.0);
1814        assert_eq!(m.scene.hits.len(), 4);
1815    }
1816    #[test]
1817    fn stacked_hundred_percent_normalizes_categories() {
1818        let mut a = Series::from_values(SeriesKind::StackedColumn, [3.0, 1.0]);
1819        a.stack_100_percent = true;
1820        let mut b = Series::from_values(SeriesKind::StackedColumn, [1.0, 4.0]);
1821        b.stack_100_percent = true;
1822        let m = CartesianChartEngine::measure_with_axes(
1823            Size::new(640.0, 400.0),
1824            &[a, b],
1825            &[],
1826            &[],
1827            &CartesianOptions::default(),
1828        )
1829        .unwrap();
1830        assert!(m.y_scales[0].max >= 100.0 && m.y_scales[0].max < 110.0);
1831    }
1832    #[test]
1833    fn nonfinite_points_are_gaps_not_renderer_inputs() {
1834        let result = scene(&[Series::from_values(
1835            SeriesKind::Line,
1836            [f64::NAN, 1.0, f64::INFINITY, 2.0],
1837        )]);
1838        assert_eq!(result.hits.len(), 2);
1839    }
1840    #[test]
1841    fn missing_axis_is_an_error() {
1842        let mut s = Series::default();
1843        s.y_axis = 2;
1844        assert!(CartesianChartEngine::measure(
1845            Size::new(640.0, 400.0),
1846            &[s],
1847            &[],
1848            &[],
1849            &CartesianOptions::default()
1850        )
1851        .is_err());
1852    }
1853    #[test]
1854    fn grouped_bars_have_distinct_centers_and_shared_category() {
1855        let result = scene(&[
1856            Series::from_values(SeriesKind::Column, [3.0]),
1857            Series::from_values(SeriesKind::Column, [4.0]),
1858        ]);
1859        assert_ne!(result.hits[0].center.x, result.hits[1].center.x);
1860    }
1861    #[test]
1862    fn all_cartesian_series_emit_measured_hits() {
1863        for kind in [
1864            SeriesKind::Line,
1865            SeriesKind::StepLine,
1866            SeriesKind::Area,
1867            SeriesKind::StackedArea,
1868            SeriesKind::StackedStepArea,
1869            SeriesKind::Column,
1870            SeriesKind::Row,
1871            SeriesKind::StackedColumn,
1872            SeriesKind::StackedRow,
1873            SeriesKind::Scatter,
1874            SeriesKind::Heat,
1875            SeriesKind::RangeColumn,
1876            SeriesKind::RangeRow,
1877            SeriesKind::RangeLine,
1878            SeriesKind::Financial,
1879            SeriesKind::Box,
1880        ] {
1881            let mut s = Series::new(kind);
1882            s.values = vec![
1883                Coordinate::box_value(0.0, 10.0, 7.0, 3.0, 1.0, 5.0),
1884                Coordinate::box_value(1.0, 12.0, 8.0, 4.0, 2.0, 6.0),
1885            ];
1886            assert_eq!(scene(&[s]).hits.len(), 2, "{kind:?}");
1887        }
1888    }
1889    #[test]
1890    fn heat_cells_infer_datetime_spacing() {
1891        let mut s = Series::new(SeriesKind::Heat);
1892        s.values = vec![
1893            Coordinate::weighted(1_000_000.0, 1.0, 3.0),
1894            Coordinate::weighted(1_100_000.0, 2.0, 4.0),
1895        ];
1896        let result = scene(&[s]);
1897        let HitShape::Rectangle(rect) = result.hits[0].shape else {
1898            panic!("expected heat rect")
1899        };
1900        assert!(rect.width > 100.0);
1901    }
1902    #[test]
1903    fn numeric_extremes_scale_without_overflow() {
1904        let s = Series::from_values(SeriesKind::Line, [-1e308, 1e308]);
1905        let result = scene(&[s]);
1906        assert_eq!(result.hits.len(), 2);
1907        assert!(result.hits.iter().all(|h| finite_point(h.center)));
1908    }
1909    #[test]
1910    fn logarithmic_axes_round_trip() {
1911        let axis = Axis {
1912            min_limit: Some(1.0),
1913            max_limit: Some(1000.0),
1914            logarithmic_base: Some(10.0),
1915            ..Axis::default()
1916        };
1917        let s = AxisScale::new(&axis, 1.0, 1000.0, 0.0, 300.0).unwrap();
1918        assert!((s.to_pixels(10.0) - 100.0).abs() < 1e-8);
1919        assert!((s.to_chart_values(200.0) - 100.0).abs() < 1e-8);
1920    }
1921    #[test]
1922    fn inverted_axis_round_trips() {
1923        let axis = Axis {
1924            inverted: true,
1925            ..Axis::default()
1926        };
1927        let s = AxisScale::new(&axis, 0.0, 10.0, 0.0, 100.0).unwrap();
1928        assert!(s.to_pixels(0.0) > s.to_pixels(10.0));
1929        assert!((s.to_chart_values(s.to_pixels(3.0)) - 3.0).abs() < 1e-10);
1930    }
1931    #[test]
1932    fn automatic_hover_compares_the_shared_category_axis() {
1933        // Kernel.Extensions.GetFindingStrategy: vertical series prefer X, rows Y,
1934        // and mixing orientations (or including scatter) compares both dimensions.
1935        let result = scene(&[Series::from_values(SeriesKind::Line, [2.0, 8.0])]);
1936        let x = result.hits[0].center.x;
1937        let pointer = Point::new(x, result.plot_bounds.y + result.plot_bounds.height * 0.5);
1938        assert_eq!(
1939            result
1940                .hit_test(pointer, FindingStrategy::Automatic, 0.0)
1941                .len(),
1942            1
1943        );
1944        assert!(result
1945            .hit_test(pointer, FindingStrategy::CompareAll, 0.0)
1946            .is_empty());
1947        let rows = scene(&[Series::from_values(SeriesKind::Row, [2.0, 8.0])]);
1948        assert_eq!(
1949            rows.automatic_finding_strategy,
1950            FindingStrategy::CompareOnlyYTakeClosest
1951        );
1952        let mixed = scene(&[
1953            Series::from_values(SeriesKind::Column, [2.0]),
1954            Series::from_values(SeriesKind::Scatter, [4.0]),
1955        ]);
1956        assert_eq!(
1957            mixed.automatic_finding_strategy,
1958            FindingStrategy::CompareAllTakeClosest
1959        );
1960    }
1961    #[test]
1962    fn range_hover_covers_band_but_exact_match_targets_either_marker() {
1963        // CoreRangeLineSeries.Invalidate gives one band hover area, while its
1964        // FindPointsInPosition override tests both high and low marker visuals.
1965        let mut range = Series::new(SeriesKind::RangeLine);
1966        range.values = vec![Coordinate::range(0.0, 10.0, 2.0)];
1967        let result = scene(&[range]);
1968        let hit = &result.hits[0];
1969        let HitShape::WithHoverArea { shape, .. } = &hit.shape else {
1970            panic!("range hover");
1971        };
1972        let HitShape::Compound(markers) = shape.as_ref() else {
1973            panic!("range endpoints");
1974        };
1975        let HitShape::Circle { center: low, .. } = markers[1] else {
1976            panic!("low endpoint");
1977        };
1978        assert_eq!(
1979            result.hit_test(low, FindingStrategy::ExactMatch, 0.0).len(),
1980            1
1981        );
1982        assert_eq!(
1983            result
1984                .hit_test(hit.center, FindingStrategy::CompareAll, 0.0)
1985                .len(),
1986            1
1987        );
1988        assert!(result
1989            .hit_test(hit.center, FindingStrategy::ExactMatch, 0.0)
1990            .is_empty());
1991        assert!(hit.label.contains('2') && hit.label.contains("10"));
1992    }
1993    #[test]
1994    fn narrow_column_hover_uses_category_slot_and_exact_match_uses_visual() {
1995        // BarSeries separates full category hover width from MaxBarWidth.
1996        let mut column = Series::from_values(SeriesKind::Column, [4.0]);
1997        column.max_bar_width = 5.0;
1998        let result = scene(&[column]);
1999        let hit = &result.hits[0];
2000        let bounds = hit.shape.hover_bounds();
2001        let pointer = Point::new(
2002            bounds.x + bounds.width * 0.25,
2003            bounds.y + bounds.height * 0.5,
2004        );
2005        assert_eq!(
2006            result
2007                .hit_test(pointer, FindingStrategy::Automatic, 0.0)
2008                .len(),
2009            1
2010        );
2011        assert!(result
2012            .hit_test(pointer, FindingStrategy::ExactMatch, 0.0)
2013            .is_empty());
2014    }
2015    #[test]
2016    fn line_fill_is_behind_markers_and_labels() {
2017        let mut area = Series::from_values(SeriesKind::Area, [1.0, 3.0, 2.0]);
2018        area.fill = Some(Color::from_rgb(255, 0, 0));
2019        area.data_labels = true;
2020        let result = scene(&[area]);
2021        let last_path = result
2022            .primitives
2023            .iter()
2024            .rposition(|p| matches!(p.geometry, Geometry::Path(_)))
2025            .unwrap();
2026        let first_marker = result
2027            .primitives
2028            .iter()
2029            .position(|p| matches!(p.geometry, Geometry::Ellipse(_)))
2030            .unwrap();
2031        assert!(last_path < first_marker);
2032    }
2033    #[test]
2034    fn duplicate_heat_series_do_not_expand_shared_bounds_twice() {
2035        let mut heat = Series::new(SeriesKind::Heat);
2036        heat.values = vec![
2037            Coordinate::weighted(0.0, 1.0, 42.0),
2038            Coordinate::weighted(2.0, 3.0, 7.0),
2039        ];
2040        let measure = |series: &[Series]| {
2041            CartesianChartEngine::measure_with_axes(
2042                Size::new(640.0, 400.0),
2043                series,
2044                &[],
2045                &[],
2046                &CartesianOptions::default(),
2047            )
2048            .unwrap()
2049        };
2050        let single = measure(&[heat.clone()]);
2051        let duplicate = measure(&[heat.clone(), heat]);
2052        assert_eq!(single.x_scales, duplicate.x_scales);
2053        assert_eq!(single.y_scales, duplicate.y_scales);
2054        assert_eq!(single.scene.hits[0].value, 42.0);
2055        assert_eq!(single.scene.hits[0].label, "42");
2056    }
2057}