Skip to main content

kuva/render/
plots.rs

1use std::sync::Arc;
2
3use crate::plot::bar::BarPlot;
4use crate::plot::boxplot::BoxPlot;
5use crate::plot::brick::BrickPlot;
6use crate::plot::histogram::Histogram;
7use crate::plot::line::LinePlot;
8use crate::plot::scatter::{ScatterPlot, TrendLine};
9use crate::plot::violin::ViolinPlot;
10
11use crate::plot::band::BandPlot;
12use crate::plot::bump::BumpPlot;
13use crate::plot::calendar::CalendarPlot;
14use crate::plot::candlestick::CandlestickPlot;
15use crate::plot::chord::ChordPlot;
16use crate::plot::clustermap::Clustermap;
17use crate::plot::contour::ContourPlot;
18use crate::plot::density::DensityPlot;
19use crate::plot::diceplot::DicePlot;
20use crate::plot::dotplot::DotPlot;
21use crate::plot::ecdf::EcdfPlot;
22use crate::plot::forest::ForestPlot;
23use crate::plot::funnel::FunnelPlot;
24use crate::plot::gantt::GanttPlot;
25use crate::plot::hexbin::HexbinPlot;
26use crate::plot::horizon::HorizonPlot;
27use crate::plot::jointplot::JointPlot;
28use crate::plot::legend::ColorBarInfo;
29use crate::plot::legend_plot::LegendPlot;
30use crate::plot::lollipop::LollipopPlot;
31use crate::plot::manhattan::ManhattanPlot;
32use crate::plot::mosaic::MosaicPlot;
33use crate::plot::network::NetworkPlot;
34use crate::plot::parallel::ParallelPlot;
35use crate::plot::phylo::PhyloTree;
36use crate::plot::polar::PolarPlot;
37use crate::plot::pr::PrPlot;
38use crate::plot::pyramid::PopulationPyramid;
39use crate::plot::qq::QQPlot;
40use crate::plot::quiver::QuiverPlot;
41use crate::plot::radar::RadarPlot;
42use crate::plot::raincloud::RaincloudPlot;
43use crate::plot::ridgeline::RidgelinePlot;
44use crate::plot::roc::RocPlot;
45use crate::plot::rose::RosePlot;
46use crate::plot::sankey::SankeyPlot;
47use crate::plot::scatter3d::Scatter3DPlot;
48use crate::plot::slope::SlopePlot;
49use crate::plot::stacked_area::StackedAreaPlot;
50use crate::plot::streamgraph::StreamgraphPlot;
51use crate::plot::strip::StripPlot;
52use crate::plot::sunburst::SunburstPlot;
53use crate::plot::surface3d::Surface3DPlot;
54use crate::plot::survival::SurvivalPlot;
55use crate::plot::synteny::SyntenyPlot;
56use crate::plot::ternary::TernaryPlot;
57use crate::plot::text::TextPlot;
58use crate::plot::treemap::TreemapPlot;
59use crate::plot::upset::UpSetPlot;
60use crate::plot::venn::VennPlot;
61use crate::plot::volcano::VolcanoPlot;
62use crate::plot::waffle::WafflePlot;
63use crate::plot::waterfall::{WaterfallKind, WaterfallPlot};
64use crate::plot::{Heatmap, Histogram2D, PiePlot, SeriesPlot};
65use crate::render::render_utils;
66
67pub enum Plot {
68    Scatter(ScatterPlot),
69    Line(LinePlot),
70    Bar(BarPlot),
71    Histogram(Histogram),
72    Histogram2d(Histogram2D),
73    Box(BoxPlot),
74    Violin(ViolinPlot),
75    Series(SeriesPlot),
76    Pie(PiePlot),
77    Heatmap(Heatmap),
78    Brick(BrickPlot),
79    Band(BandPlot),
80    Waterfall(WaterfallPlot),
81    Strip(StripPlot),
82    Volcano(VolcanoPlot),
83    Manhattan(ManhattanPlot),
84    DotPlot(DotPlot),
85    UpSet(UpSetPlot),
86    StackedArea(StackedAreaPlot),
87    Candlestick(CandlestickPlot),
88    Contour(ContourPlot),
89    Chord(ChordPlot),
90    Sankey(SankeyPlot),
91    PhyloTree(PhyloTree),
92    Synteny(SyntenyPlot),
93    Density(DensityPlot),
94    Ridgeline(RidgelinePlot),
95    Polar(PolarPlot),
96    Ternary(TernaryPlot),
97    DicePlot(DicePlot),
98    Forest(ForestPlot),
99    Scatter3D(Scatter3DPlot),
100    Surface3D(Surface3DPlot),
101    Clustermap(Clustermap),
102    Joint(JointPlot),
103    Raincloud(RaincloudPlot),
104    Lollipop(LollipopPlot),
105    Survival(SurvivalPlot),
106    Roc(RocPlot),
107    Pr(PrPlot),
108    Slope(SlopePlot),
109    Venn(VennPlot),
110    Parallel(ParallelPlot),
111    Mosaic(MosaicPlot),
112    Ecdf(EcdfPlot),
113    QQ(QQPlot),
114    Network(NetworkPlot),
115    Streamgraph(StreamgraphPlot),
116    Radar(RadarPlot),
117    Hexbin(HexbinPlot),
118    Treemap(TreemapPlot),
119    Sunburst(SunburstPlot),
120    Bump(BumpPlot),
121    Funnel(FunnelPlot),
122    Rose(RosePlot),
123    Calendar(CalendarPlot),
124    Pyramid(PopulationPyramid),
125    Waffle(WafflePlot),
126    Horizon(HorizonPlot),
127    Gantt(GanttPlot),
128    Text(TextPlot),
129    LegendPlot(LegendPlot),
130    Quiver(QuiverPlot),
131}
132
133impl From<ScatterPlot> for Plot {
134    fn from(p: ScatterPlot) -> Self {
135        Plot::Scatter(p)
136    }
137}
138impl From<LinePlot> for Plot {
139    fn from(p: LinePlot) -> Self {
140        Plot::Line(p)
141    }
142}
143impl From<BarPlot> for Plot {
144    fn from(p: BarPlot) -> Self {
145        Plot::Bar(p)
146    }
147}
148impl From<Histogram> for Plot {
149    fn from(p: Histogram) -> Self {
150        Plot::Histogram(p)
151    }
152}
153impl From<Histogram2D> for Plot {
154    fn from(p: Histogram2D) -> Self {
155        Plot::Histogram2d(p)
156    }
157}
158impl From<BoxPlot> for Plot {
159    fn from(p: BoxPlot) -> Self {
160        Plot::Box(p)
161    }
162}
163impl From<ViolinPlot> for Plot {
164    fn from(p: ViolinPlot) -> Self {
165        Plot::Violin(p)
166    }
167}
168impl From<SeriesPlot> for Plot {
169    fn from(p: SeriesPlot) -> Self {
170        Plot::Series(p)
171    }
172}
173impl From<PiePlot> for Plot {
174    fn from(p: PiePlot) -> Self {
175        Plot::Pie(p)
176    }
177}
178impl From<Heatmap> for Plot {
179    fn from(p: Heatmap) -> Self {
180        Plot::Heatmap(p)
181    }
182}
183impl From<BrickPlot> for Plot {
184    fn from(p: BrickPlot) -> Self {
185        Plot::Brick(p)
186    }
187}
188impl From<BandPlot> for Plot {
189    fn from(p: BandPlot) -> Self {
190        Plot::Band(p)
191    }
192}
193impl From<WaterfallPlot> for Plot {
194    fn from(p: WaterfallPlot) -> Self {
195        Plot::Waterfall(p)
196    }
197}
198impl From<StripPlot> for Plot {
199    fn from(p: StripPlot) -> Self {
200        Plot::Strip(p)
201    }
202}
203impl From<VolcanoPlot> for Plot {
204    fn from(p: VolcanoPlot) -> Self {
205        Plot::Volcano(p)
206    }
207}
208impl From<ManhattanPlot> for Plot {
209    fn from(p: ManhattanPlot) -> Self {
210        Plot::Manhattan(p)
211    }
212}
213impl From<DotPlot> for Plot {
214    fn from(p: DotPlot) -> Self {
215        Plot::DotPlot(p)
216    }
217}
218impl From<UpSetPlot> for Plot {
219    fn from(p: UpSetPlot) -> Self {
220        Plot::UpSet(p)
221    }
222}
223impl From<StackedAreaPlot> for Plot {
224    fn from(p: StackedAreaPlot) -> Self {
225        Plot::StackedArea(p)
226    }
227}
228impl From<CandlestickPlot> for Plot {
229    fn from(p: CandlestickPlot) -> Self {
230        Plot::Candlestick(p)
231    }
232}
233impl From<ContourPlot> for Plot {
234    fn from(p: ContourPlot) -> Self {
235        Plot::Contour(p)
236    }
237}
238impl From<ChordPlot> for Plot {
239    fn from(p: ChordPlot) -> Self {
240        Plot::Chord(p)
241    }
242}
243impl From<SankeyPlot> for Plot {
244    fn from(p: SankeyPlot) -> Self {
245        Plot::Sankey(p)
246    }
247}
248impl From<PhyloTree> for Plot {
249    fn from(p: PhyloTree) -> Self {
250        Plot::PhyloTree(p)
251    }
252}
253impl From<SyntenyPlot> for Plot {
254    fn from(p: SyntenyPlot) -> Self {
255        Plot::Synteny(p)
256    }
257}
258impl From<DensityPlot> for Plot {
259    fn from(p: DensityPlot) -> Self {
260        Plot::Density(p)
261    }
262}
263impl From<RidgelinePlot> for Plot {
264    fn from(p: RidgelinePlot) -> Self {
265        Plot::Ridgeline(p)
266    }
267}
268impl From<PolarPlot> for Plot {
269    fn from(p: PolarPlot) -> Self {
270        Plot::Polar(p)
271    }
272}
273impl From<TernaryPlot> for Plot {
274    fn from(p: TernaryPlot) -> Self {
275        Plot::Ternary(p)
276    }
277}
278impl From<DicePlot> for Plot {
279    fn from(p: DicePlot) -> Self {
280        Plot::DicePlot(p)
281    }
282}
283impl From<ForestPlot> for Plot {
284    fn from(p: ForestPlot) -> Self {
285        Plot::Forest(p)
286    }
287}
288impl From<Scatter3DPlot> for Plot {
289    fn from(p: Scatter3DPlot) -> Self {
290        Plot::Scatter3D(p)
291    }
292}
293impl From<Surface3DPlot> for Plot {
294    fn from(p: Surface3DPlot) -> Self {
295        Plot::Surface3D(p)
296    }
297}
298impl From<Clustermap> for Plot {
299    fn from(p: Clustermap) -> Self {
300        Plot::Clustermap(p)
301    }
302}
303impl From<JointPlot> for Plot {
304    fn from(p: JointPlot) -> Self {
305        Plot::Joint(p)
306    }
307}
308impl From<RaincloudPlot> for Plot {
309    fn from(p: RaincloudPlot) -> Self {
310        Plot::Raincloud(p)
311    }
312}
313impl From<LollipopPlot> for Plot {
314    fn from(p: LollipopPlot) -> Self {
315        Plot::Lollipop(p)
316    }
317}
318impl From<SurvivalPlot> for Plot {
319    fn from(p: SurvivalPlot) -> Self {
320        Plot::Survival(p)
321    }
322}
323impl From<RocPlot> for Plot {
324    fn from(p: RocPlot) -> Self {
325        Plot::Roc(p)
326    }
327}
328impl From<PrPlot> for Plot {
329    fn from(p: PrPlot) -> Self {
330        Plot::Pr(p)
331    }
332}
333impl From<SlopePlot> for Plot {
334    fn from(p: SlopePlot) -> Self {
335        Plot::Slope(p)
336    }
337}
338impl From<VennPlot> for Plot {
339    fn from(p: VennPlot) -> Self {
340        Plot::Venn(p)
341    }
342}
343impl From<ParallelPlot> for Plot {
344    fn from(p: ParallelPlot) -> Self {
345        Plot::Parallel(p)
346    }
347}
348impl From<MosaicPlot> for Plot {
349    fn from(p: MosaicPlot) -> Self {
350        Plot::Mosaic(p)
351    }
352}
353impl From<EcdfPlot> for Plot {
354    fn from(p: EcdfPlot) -> Self {
355        Plot::Ecdf(p)
356    }
357}
358impl From<QQPlot> for Plot {
359    fn from(p: QQPlot) -> Self {
360        Plot::QQ(p)
361    }
362}
363impl From<NetworkPlot> for Plot {
364    fn from(p: NetworkPlot) -> Self {
365        Plot::Network(p)
366    }
367}
368impl From<StreamgraphPlot> for Plot {
369    fn from(p: StreamgraphPlot) -> Self {
370        Plot::Streamgraph(p)
371    }
372}
373impl From<RadarPlot> for Plot {
374    fn from(p: RadarPlot) -> Self {
375        Plot::Radar(p)
376    }
377}
378impl From<HexbinPlot> for Plot {
379    fn from(p: HexbinPlot) -> Self {
380        Plot::Hexbin(p)
381    }
382}
383impl From<TreemapPlot> for Plot {
384    fn from(p: TreemapPlot) -> Self {
385        Plot::Treemap(p)
386    }
387}
388impl From<SunburstPlot> for Plot {
389    fn from(p: SunburstPlot) -> Self {
390        Plot::Sunburst(p)
391    }
392}
393impl From<BumpPlot> for Plot {
394    fn from(p: BumpPlot) -> Self {
395        Plot::Bump(p)
396    }
397}
398impl From<FunnelPlot> for Plot {
399    fn from(p: FunnelPlot) -> Self {
400        Plot::Funnel(p)
401    }
402}
403impl From<RosePlot> for Plot {
404    fn from(p: RosePlot) -> Self {
405        Plot::Rose(p)
406    }
407}
408impl From<CalendarPlot> for Plot {
409    fn from(p: CalendarPlot) -> Self {
410        Plot::Calendar(p)
411    }
412}
413impl From<PopulationPyramid> for Plot {
414    fn from(p: PopulationPyramid) -> Self {
415        Plot::Pyramid(p)
416    }
417}
418impl From<WafflePlot> for Plot {
419    fn from(p: WafflePlot) -> Self {
420        Plot::Waffle(p)
421    }
422}
423impl From<HorizonPlot> for Plot {
424    fn from(p: HorizonPlot) -> Self {
425        Plot::Horizon(p)
426    }
427}
428impl From<GanttPlot> for Plot {
429    fn from(p: GanttPlot) -> Self {
430        Plot::Gantt(p)
431    }
432}
433impl From<TextPlot> for Plot {
434    fn from(p: TextPlot) -> Self {
435        Plot::Text(p)
436    }
437}
438impl From<LegendPlot> for Plot {
439    fn from(p: LegendPlot) -> Self {
440        Plot::LegendPlot(p)
441    }
442}
443impl From<QuiverPlot> for Plot {
444    fn from(p: QuiverPlot) -> Self {
445        Plot::Quiver(p)
446    }
447}
448
449use crate::plot::colormap::ColorMap;
450use crate::plot::plot3d::DataRanges3D;
451
452fn colorbar_from_z(
453    cmap: &ColorMap,
454    ranges: DataRanges3D,
455    label: Option<String>,
456) -> Option<ColorBarInfo> {
457    let (z_min, z_max) = ranges.z;
458    if !z_min.is_finite() || !z_max.is_finite() {
459        return None;
460    }
461    colorbar_linear(cmap, z_min, z_max, label)
462}
463
464/// Standard linearly-normalized colorbar: `map_fn(t) = cmap((t - min) / (max - min))`,
465/// clamped to `[0, 1]`. Used by every continuous-colormap plot.
466pub(crate) fn colorbar_linear(
467    cmap: &ColorMap,
468    min: f64,
469    max: f64,
470    label: Option<String>,
471) -> Option<ColorBarInfo> {
472    if !min.is_finite() || !max.is_finite() {
473        return None;
474    }
475    let cmap = cmap.clone();
476    Some(ColorBarInfo {
477        map_fn: Arc::new(move |t| {
478            let norm = (t - min) / (max - min + f64::EPSILON);
479            cmap.map(norm.clamp(0.0, 1.0))
480        }),
481        min_value: min,
482        max_value: max,
483        label,
484        tick_labels: None,
485        tick_values: None,
486    })
487}
488
489fn bounds_from_2d<I>(points: I) -> Option<((f64, f64), (f64, f64))>
490where
491    I: IntoIterator,
492    I::Item: Into<(f64, f64)>,
493{
494    let mut iter = points.into_iter().map(Into::into);
495    let (x0, y0) = iter.next()?;
496    let (mut x_min, mut x_max) = (x0, x0);
497    let (mut y_min, mut y_max) = (y0, y0);
498    for (x, y) in iter {
499        x_min = x_min.min(x);
500        x_max = x_max.max(x);
501        y_min = y_min.min(y);
502        y_max = y_max.max(y);
503    }
504    Some(((x_min, x_max), (y_min, y_max)))
505}
506
507fn _bounds_from_1d(points: &[f64]) -> Option<((f64, f64), (f64, f64))> {
508    if points.is_empty() {
509        return None;
510    }
511    let (mut min_val, mut max_val) = (points[0], points[0]);
512    for i in points {
513        min_val = min_val.min(*i);
514        max_val = max_val.max(*i);
515    }
516
517    Some(((0.0f64, points.len() as f64), (min_val, max_val)))
518}
519
520impl Plot {
521    /// Set the primary color for single-color plot types.
522    /// Multi-element plots (Bar, Pie, Brick) and grid plots (Heatmap, Histogram2d) are skipped.
523    pub fn set_color(&mut self, color: &str) {
524        match self {
525            Plot::Scatter(s) => s.color = color.into(),
526            Plot::Line(l) => l.color = color.into(),
527            Plot::Series(s) => s.color = color.into(),
528            Plot::Histogram(h) => h.color = color.into(),
529            Plot::Box(b) => b.color = color.into(),
530            Plot::Violin(v) => v.color = color.into(),
531            Plot::Band(b) => b.color = color.into(),
532            Plot::Strip(s) => s.color = color.into(),
533            Plot::Density(d) => d.color = color.into(),
534            Plot::Forest(f) => f.color = color.into(),
535            Plot::Scatter3D(s) => s.color = color.into(),
536            Plot::Surface3D(s) => s.color = color.into(),
537            Plot::Raincloud(r) => r.color = color.into(),
538            Plot::Lollipop(l) => l.color = color.into(),
539            Plot::Survival(s) => s.color = color.into(),
540            Plot::Roc(r) => r.color = color.into(),
541            Plot::Pr(r) => r.color = color.into(),
542            Plot::Slope(s) => s.color = color.into(),
543            Plot::Parallel(p) => p.color = color.into(),
544            Plot::Ecdf(e) => e.color = color.into(),
545            Plot::QQ(q) => q.color = color.into(),
546            Plot::Quiver(q) => q.color = color.into(),
547            _ => {} // multi-series plots (StackedArea, Streamgraph, etc.) skip palette auto-assign
548        }
549    }
550
551    pub fn bounds(&self) -> Option<((f64, f64), (f64, f64))> {
552        match self {
553            Plot::Scatter(s) => {
554                let ((mut x_min, mut x_max), (mut y_min, mut y_max)) = bounds_from_2d(&s.data)?;
555
556                // Expand with error bars
557                for point in &s.data {
558                    let x_lo = point.x - point.x_err.map_or(0.0, |e| e.0);
559                    let x_hi = point.x + point.x_err.map_or(0.0, |e| e.1);
560                    let y_lo = point.y - point.y_err.map_or(0.0, |e| e.0);
561                    let y_hi = point.y + point.y_err.map_or(0.0, |e| e.1);
562
563                    x_min = x_min.min(x_lo);
564                    x_max = x_max.max(x_hi);
565                    y_min = y_min.min(y_lo);
566                    y_max = y_max.max(y_hi);
567                }
568
569                // Expand for band
570                if let Some(ref band) = s.band {
571                    for &y in &band.y_lower {
572                        y_min = y_min.min(y);
573                    }
574                    for &y in &band.y_upper {
575                        y_max = y_max.max(y);
576                    }
577                }
578
579                // Expand for trend line
580                if let Some(trend) = s.trend {
581                    let TrendLine::Linear = trend;
582                    if let Some((slope, intercept, _)) = render_utils::linear_regression(&s.data) {
583                        let y_start = slope * x_min + intercept;
584                        let y_end = slope * x_max + intercept;
585
586                        y_min = y_min.min(y_start).min(y_end);
587                        y_max = y_max.max(y_start).max(y_end);
588                    }
589                }
590
591                Some(((x_min, x_max), (y_min, y_max)))
592            }
593            Plot::Line(p) => {
594                let ((x_min, x_max), (mut y_min, mut y_max)) = bounds_from_2d(&p.data)?;
595                if let Some(ref band) = p.band {
596                    for &y in &band.y_lower {
597                        y_min = y_min.min(y);
598                    }
599                    for &y in &band.y_upper {
600                        y_max = y_max.max(y);
601                    }
602                }
603                Some(((x_min, x_max), (y_min, y_max)))
604            }
605            // Plot::Series(s) => bounds_from_1d(&s.values),
606            Plot::Series(sp) => {
607                if sp.values.is_empty() {
608                    None
609                } else {
610                    let x_min = 0.0;
611                    let x_max = sp.values.len() as f64 - 1.0;
612
613                    let mut y_min = f64::INFINITY;
614                    let mut y_max = f64::NEG_INFINITY;
615
616                    for &v in &sp.values {
617                        y_min = y_min.min(v);
618                        y_max = y_max.max(v);
619                    }
620
621                    Some(((x_min, x_max), (y_min, y_max)))
622                }
623            }
624            Plot::Bar(bp) => {
625                if bp.groups.is_empty() {
626                    None
627                } else {
628                    let cat_min = 0.5;
629                    let cat_max = bp.groups.len() as f64 + 0.5;
630                    let data_min = 0.0;
631
632                    let mut data_max = f64::NEG_INFINITY;
633                    if bp.stacked {
634                        for group in &bp.groups {
635                            let sum: f64 = group.bars.iter().map(|b| b.value).sum();
636                            data_max = data_max.max(sum);
637                        }
638                    } else {
639                        for group in &bp.groups {
640                            for bar in &group.bars {
641                                data_max = data_max.max(bar.value);
642                            }
643                        }
644                    }
645
646                    if bp.horizontal {
647                        Some(((data_min, data_max), (cat_min, cat_max)))
648                    } else {
649                        Some(((cat_min, cat_max), (data_min, data_max)))
650                    }
651                }
652            }
653            Plot::Histogram(h) => {
654                // Precomputed path: derive bounds from edges and counts directly
655                if let Some((edges, counts)) = &h.precomputed {
656                    if edges.len() < 2 || counts.is_empty() {
657                        return None;
658                    }
659                    let x_min = edges[0];
660                    let x_max = *edges.last().unwrap();
661                    let max_y = if h.normalize {
662                        1.0
663                    } else {
664                        counts.iter().cloned().fold(0.0_f64, f64::max)
665                    };
666                    return Some(((x_min, x_max), (0.0, max_y)));
667                }
668                // Auto-binning path: use explicit range if set, else derive from data
669                // (mirrors the fallback in the renderer so bounds() always returns a usable range)
670                let range = h.range.unwrap_or_else(|| {
671                    if h.data.is_empty() {
672                        return (0.0, 1.0);
673                    }
674                    let min = h.data.iter().cloned().fold(f64::INFINITY, f64::min);
675                    let max = h.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
676                    (min, max)
677                });
678                let bins = h.bins;
679                let bin_width = (range.1 - range.0) / bins as f64;
680
681                let mut counts = vec![0usize; bins];
682                for &value in &h.data {
683                    if value < range.0 || value > range.1 {
684                        continue;
685                    }
686                    let bin = ((value - range.0) / bin_width).floor() as usize;
687                    let bin = if bin == bins { bin - 1 } else { bin };
688                    counts[bin] += 1;
689                }
690
691                let max_y = if h.normalize {
692                    1.0
693                } else {
694                    *counts.iter().max().unwrap_or(&1) as f64
695                };
696
697                Some((range, (0.0, max_y)))
698            }
699            Plot::Box(bp) => {
700                if bp.groups.is_empty() {
701                    None
702                } else {
703                    let cat_min = 0.5;
704                    let cat_max = bp.groups.len() as f64 + 0.5;
705
706                    let mut data_min = f64::INFINITY;
707                    let mut data_max = f64::NEG_INFINITY;
708                    for g in &bp.groups {
709                        if g.values.is_empty() {
710                            continue;
711                        }
712                        let mut vals = g.values.clone();
713                        vals.sort_by(|a, b| a.total_cmp(b));
714                        let q1 = render_utils::percentile(&vals, 25.0);
715                        let q3 = render_utils::percentile(&vals, 75.0);
716                        let iqr = q3 - q1;
717                        let lo = q1 - 1.5 * iqr;
718                        let hi = q3 + 1.5 * iqr;
719                        data_min = data_min.min(lo);
720                        data_max = data_max.max(hi);
721                    }
722
723                    if bp.horizontal {
724                        Some(((data_min, data_max), (cat_min, cat_max)))
725                    } else {
726                        Some(((cat_min, cat_max), (data_min, data_max)))
727                    }
728                }
729            }
730            Plot::Violin(vp) => {
731                if vp.groups.is_empty() {
732                    None
733                } else {
734                    let cat_min = 0.5;
735                    let cat_max = vp.groups.len() as f64 + 0.5;
736
737                    let mut data_min = f64::INFINITY;
738                    let mut data_max = f64::NEG_INFINITY;
739
740                    for group in &vp.groups {
741                        if group.values.is_empty() {
742                            continue;
743                        }
744                        let g_min = group.values.iter().cloned().fold(f64::INFINITY, f64::min);
745                        let g_max = group
746                            .values
747                            .iter()
748                            .cloned()
749                            .fold(f64::NEG_INFINITY, f64::max);
750                        let h = vp
751                            .bandwidth
752                            .unwrap_or_else(|| render_utils::silverman_bandwidth(&group.values));
753                        data_min = data_min.min(g_min - 3.0 * h);
754                        data_max = data_max.max(g_max + 3.0 * h);
755                    }
756
757                    if vp.horizontal {
758                        Some(((data_min, data_max), (cat_min, cat_max)))
759                    } else {
760                        Some(((cat_min, cat_max), (data_min, data_max)))
761                    }
762                }
763            }
764            Plot::Pie(_) => {
765                // Centered at (0.0, 0.0) and rendered to fit the layout box
766                Some(((-1.0, 1.0), (-1.0, 1.0)))
767            }
768            Plot::Heatmap(hm) => {
769                let rows = hm.data.len();
770                let cols = hm.data.first().map_or(0, |row| row.len());
771                let x = hm.x_range.unwrap_or((0.5, cols as f64 + 0.5));
772                let y = hm.y_range.unwrap_or((0.5, rows as f64 + 0.5));
773                Some((x, y))
774            }
775            Plot::Histogram2d(h2d) => {
776                // Return the physical axis range so the layout is calibrated in
777                // data coordinates, matching the physical coords used by the renderer.
778                Some((
779                    (h2d.x_range.0, h2d.x_range.1),
780                    (h2d.y_range.0, h2d.y_range.1),
781                ))
782            }
783            Plot::Band(b) => {
784                if b.x.is_empty() {
785                    return None;
786                }
787                let x_min = b.x.iter().cloned().fold(f64::INFINITY, f64::min);
788                let x_max = b.x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
789                let y_min = b.y_lower.iter().cloned().fold(f64::INFINITY, f64::min);
790                let y_max = b.y_upper.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
791                Some(((x_min, x_max), (y_min, y_max)))
792            }
793            Plot::Waterfall(wp) => {
794                if wp.bars.is_empty() {
795                    return None;
796                }
797                let x_min = 0.5;
798                let x_max = wp.bars.len() as f64 + 0.5;
799                let mut running = 0.0_f64;
800                let mut y_min = 0.0_f64;
801                let mut y_max = 0.0_f64;
802                for bar in &wp.bars {
803                    match bar.kind {
804                        WaterfallKind::Delta => {
805                            let base = running;
806                            running += bar.value;
807                            y_min = y_min.min(base).min(running);
808                            y_max = y_max.max(base).max(running);
809                        }
810                        WaterfallKind::Total => {
811                            y_min = y_min.min(0.0).min(running);
812                            y_max = y_max.max(0.0).max(running);
813                        }
814                        WaterfallKind::Difference { from, to } => {
815                            y_min = y_min.min(from).min(to);
816                            y_max = y_max.max(from).max(to);
817                        }
818                    }
819                }
820                Some(((x_min, x_max), (y_min, y_max)))
821            }
822            Plot::Strip(sp) => {
823                if sp.groups.is_empty() {
824                    return None;
825                }
826                let x_min = 0.5;
827                let x_max = sp.groups.len() as f64 + 0.5;
828                let mut y_min = f64::INFINITY;
829                let mut y_max = f64::NEG_INFINITY;
830                for g in &sp.groups {
831                    for &v in &g.values {
832                        y_min = y_min.min(v);
833                        y_max = y_max.max(v);
834                    }
835                }
836                if y_min == f64::INFINITY {
837                    return None;
838                }
839                Some(((x_min, x_max), (y_min, y_max)))
840            }
841            Plot::Volcano(vp) => {
842                if vp.points.is_empty() {
843                    return None;
844                }
845                let floor = vp.floor();
846                let mut x_min = f64::INFINITY;
847                let mut x_max = f64::NEG_INFINITY;
848                let mut y_max = f64::NEG_INFINITY;
849                for p in &vp.points {
850                    x_min = x_min.min(p.log2fc);
851                    x_max = x_max.max(p.log2fc);
852                    let y = -(p.pvalue.max(floor)).log10();
853                    y_max = y_max.max(y);
854                }
855                Some(((x_min, x_max), (0.0, y_max)))
856            }
857            Plot::Manhattan(mp) => {
858                if mp.points.is_empty() {
859                    return None;
860                }
861                let floor = mp.floor();
862                let x_min = mp
863                    .spans
864                    .iter()
865                    .map(|s| s.x_start)
866                    .fold(f64::INFINITY, f64::min);
867                let x_max = mp
868                    .spans
869                    .iter()
870                    .map(|s| s.x_end)
871                    .fold(f64::NEG_INFINITY, f64::max);
872                if !x_min.is_finite() {
873                    return None;
874                }
875                // Ensure genome-wide threshold is always visible
876                let y_max = mp
877                    .points
878                    .iter()
879                    .map(|p| -(p.pvalue.max(floor)).log10())
880                    .fold(mp.genome_wide, f64::max);
881                Some(((x_min, x_max), (0.0, y_max)))
882            }
883            Plot::DotPlot(dp) => {
884                if dp.x_categories.is_empty() {
885                    return None;
886                }
887                Some((
888                    (0.5, dp.x_categories.len() as f64 + 0.5),
889                    (0.5, dp.y_categories.len() as f64 + 0.5),
890                ))
891            }
892            Plot::DicePlot(dp) => {
893                if dp.x_categories.is_empty() {
894                    return None;
895                }
896                Some((
897                    (0.5, dp.x_categories.len() as f64 + 0.5),
898                    (0.5, dp.y_categories.len() as f64 + 0.5),
899                ))
900            }
901            Plot::UpSet(_) => {
902                // Dummy bounds — UpSet renders in pixel space and ignores map_x/map_y.
903                Some(((0.0, 1.0), (0.0, 1.0)))
904            }
905            Plot::StackedArea(sa) => {
906                if sa.x.is_empty() || sa.series.is_empty() {
907                    return None;
908                }
909                let x_min = sa.x.iter().cloned().fold(f64::INFINITY, f64::min);
910                let x_max = sa.x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
911                let n = sa.x.len();
912                let y_max = if sa.normalized {
913                    100.0
914                } else {
915                    (0..n)
916                        .map(|i| {
917                            sa.series
918                                .iter()
919                                .map(|s| s.get(i).copied().unwrap_or(0.0))
920                                .sum::<f64>()
921                        })
922                        .fold(0.0_f64, f64::max)
923                };
924                Some(((x_min, x_max), (0.0, y_max)))
925            }
926            Plot::Candlestick(cp) => {
927                if cp.candles.is_empty() {
928                    return None;
929                }
930                let continuous = cp.candles.iter().any(|c| c.x.is_some());
931                let (x_min, x_max) = if continuous {
932                    (
933                        cp.candles
934                            .iter()
935                            .filter_map(|c| c.x)
936                            .fold(f64::INFINITY, f64::min),
937                        cp.candles
938                            .iter()
939                            .filter_map(|c| c.x)
940                            .fold(f64::NEG_INFINITY, f64::max),
941                    )
942                } else {
943                    (0.5, cp.candles.len() as f64 + 0.5)
944                };
945                let y_min = cp
946                    .candles
947                    .iter()
948                    .map(|c| c.low)
949                    .fold(f64::INFINITY, f64::min);
950                let y_max = cp
951                    .candles
952                    .iter()
953                    .map(|c| c.high)
954                    .fold(f64::NEG_INFINITY, f64::max);
955                Some(((x_min, x_max), (y_min, y_max)))
956            }
957            Plot::Contour(cp) => {
958                if cp.z.is_empty() {
959                    return None;
960                }
961                let x_min = cp.x_coords.iter().cloned().fold(f64::INFINITY, f64::min);
962                let x_max = cp
963                    .x_coords
964                    .iter()
965                    .cloned()
966                    .fold(f64::NEG_INFINITY, f64::max);
967                let y_min = cp.y_coords.iter().cloned().fold(f64::INFINITY, f64::min);
968                let y_max = cp
969                    .y_coords
970                    .iter()
971                    .cloned()
972                    .fold(f64::NEG_INFINITY, f64::max);
973                Some(((x_min, x_max), (y_min, y_max)))
974            }
975            Plot::Chord(_) => {
976                // Rendered in pixel space; dummy bounds satisfy Layout::auto_from_plots.
977                Some(((0.0, 1.0), (0.0, 1.0)))
978            }
979            Plot::Sankey(_) => {
980                // Rendered in pixel space; dummy bounds satisfy Layout::auto_from_plots.
981                Some(((0.0, 1.0), (0.0, 1.0)))
982            }
983            Plot::PhyloTree(_) => {
984                // Rendered in pixel space; dummy bounds satisfy Layout::auto_from_plots.
985                Some(((0.0, 1.0), (0.0, 1.0)))
986            }
987            Plot::Synteny(_) => {
988                // Rendered in pixel space; dummy bounds satisfy Layout::auto_from_plots.
989                Some(((0.0, 1.0), (0.0, 1.0)))
990            }
991            Plot::Density(dp) => {
992                // Use precomputed curve if available
993                if let Some((xs, ys)) = &dp.precomputed {
994                    if xs.is_empty() {
995                        return None;
996                    }
997                    let x_min = xs.iter().cloned().fold(f64::INFINITY, f64::min);
998                    let x_max = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
999                    let y_min = if dp.fit_y {
1000                        ys.iter().cloned().fold(f64::INFINITY, f64::min)
1001                    } else {
1002                        0.0
1003                    };
1004                    let y_max = ys.iter().cloned().fold(0.0_f64, f64::max);
1005                    return Some(((x_min, x_max), (y_min, y_max * 1.1)));
1006                }
1007                if dp.data.len() < 2 {
1008                    return None;
1009                }
1010                let bw = dp
1011                    .bandwidth
1012                    .unwrap_or_else(|| render_utils::silverman_bandwidth(&dp.data));
1013                let data_min = dp.data.iter().cloned().fold(f64::INFINITY, f64::min);
1014                let data_max = dp.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1015                let x_min = dp.x_lo.unwrap_or(data_min - 3.0 * bw);
1016                let x_max = dp.x_hi.unwrap_or(data_max + 3.0 * bw);
1017                // Use the same KDE path as the renderer (including reflection) so
1018                // bounds() and the rendered curve agree on the peak y value.
1019                let n = dp.data.len() as f64;
1020                let norm = 1.0 / (n * bw * (2.0 * std::f64::consts::PI).sqrt());
1021                let curve = if dp.x_lo.is_some() || dp.x_hi.is_some() {
1022                    render_utils::simple_kde_reflect(
1023                        &dp.data,
1024                        bw,
1025                        dp.kde_samples,
1026                        x_min,
1027                        x_max,
1028                        dp.x_lo.is_some(),
1029                        dp.x_hi.is_some(),
1030                    )
1031                } else {
1032                    render_utils::simple_kde(&dp.data, bw, dp.kde_samples)
1033                };
1034                let y_max_pdf = curve.iter().map(|(_, y)| y * norm).fold(0.0_f64, f64::max);
1035                Some(((x_min, x_max), (0.0, y_max_pdf * 1.1)))
1036            }
1037            Plot::Ridgeline(rp) => {
1038                if rp.groups.is_empty() {
1039                    return None;
1040                }
1041                let n = rp.groups.len() as f64;
1042                let mut x_min = f64::INFINITY;
1043                let mut x_max = f64::NEG_INFINITY;
1044                for g in &rp.groups {
1045                    if g.values.is_empty() {
1046                        continue;
1047                    }
1048                    let bw = rp
1049                        .bandwidth
1050                        .unwrap_or_else(|| render_utils::silverman_bandwidth(&g.values));
1051                    let gmin = g.values.iter().cloned().fold(f64::INFINITY, f64::min);
1052                    let gmax = g.values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1053                    x_min = x_min.min(gmin - 3.0 * bw);
1054                    x_max = x_max.max(gmax + 3.0 * bw);
1055                }
1056                if !x_min.is_finite() {
1057                    return None;
1058                }
1059                // y_max must leave room for the top ridge to extend (1+overlap)
1060                // data units above group 0's center (at y = n).  Half a unit of
1061                // additional padding keeps it off the very top of the plot area.
1062                Some(((x_min, x_max), (0.5, n + 1.5 + rp.overlap)))
1063            }
1064            Plot::Polar(_) => {
1065                // Rendered in pixel space; dummy bounds satisfy Layout::auto_from_plots.
1066                Some(((-1.0, 1.0), (-1.0, 1.0)))
1067            }
1068            Plot::Ternary(_) => {
1069                // Rendered in pixel space; dummy bounds satisfy Layout::auto_from_plots.
1070                Some(((-1.0, 1.0), (-1.0, 1.0)))
1071            }
1072            Plot::Brick(bp) => {
1073                let rows = if let Some(ref exp) = bp.strigar_exp {
1074                    exp.len()
1075                } else {
1076                    bp.sequences.len()
1077                };
1078
1079                let n_rows = rows;
1080
1081                // STR width for row i (in data units, excluding flanks).
1082                let str_width = |i: usize| -> f64 {
1083                    if let Some(ref exp) = bp.strigar_exp {
1084                        if let Some(ref ml) = bp.motif_lengths {
1085                            exp.get(i)
1086                                .map(|s| {
1087                                    s.chars()
1088                                        .map(|c| *ml.get(&c).unwrap_or(&1) as f64)
1089                                        .sum::<f64>()
1090                                })
1091                                .unwrap_or(0.0)
1092                        } else {
1093                            exp.get(i).map(|s| s.len() as f64).unwrap_or(0.0)
1094                        }
1095                    } else {
1096                        bp.sequences.get(i).map(|s| s.len() as f64).unwrap_or(0.0)
1097                    }
1098                };
1099                let left_len = |i: usize| -> f64 {
1100                    bp.left_flanks
1101                        .as_ref()
1102                        .and_then(|f| f.get(i))
1103                        .map(|s| s.chars().count() as f64)
1104                        .unwrap_or(0.0)
1105                };
1106                let right_len = |i: usize| -> f64 {
1107                    bp.right_flanks
1108                        .as_ref()
1109                        .and_then(|f| f.get(i))
1110                        .map(|s| s.chars().count() as f64)
1111                        .unwrap_or(0.0)
1112                };
1113
1114                // For right-anchor, all trailing edges align at max(str_width + right_len).
1115                // The right-align shift per row is max_right - row_right, which moves shorter
1116                // rows rightward. x_lo / x_hi must account for this shift.
1117                use crate::plot::BrickAnchor;
1118                let right_edges: Vec<f64> =
1119                    (0..n_rows).map(|i| str_width(i) + right_len(i)).collect();
1120                let max_right = right_edges.iter().cloned().fold(0.0_f64, f64::max);
1121                let ra_shift = |i: usize| -> f64 {
1122                    if bp.anchor == BrickAnchor::Right {
1123                        max_right - right_edges[i]
1124                    } else {
1125                        0.0
1126                    }
1127                };
1128
1129                let row_base_off = |i: usize| -> f64 {
1130                    let per_row = if let Some(ref offsets) = bp.x_offsets {
1131                        offsets.get(i).copied().flatten().unwrap_or(bp.x_offset)
1132                    } else {
1133                        bp.x_offset
1134                    };
1135                    per_row + bp.x_origin
1136                };
1137
1138                // x extent: from leftmost flank to rightmost trailing edge across all rows.
1139                let mut lo = f64::INFINITY;
1140                let mut hi = f64::NEG_INFINITY;
1141                for i in 0..n_rows {
1142                    let eff_off = row_base_off(i) - ra_shift(i);
1143                    lo = lo.min(-left_len(i) - eff_off);
1144                    hi = hi.max(str_width(i) + right_len(i) - eff_off);
1145                }
1146                if !lo.is_finite() {
1147                    lo = 0.0;
1148                }
1149                if !hi.is_finite() {
1150                    hi = 1.0;
1151                }
1152
1153                Some(((lo, hi), (0.0, rows as f64)))
1154            }
1155            Plot::Forest(fp) => {
1156                if fp.rows.is_empty() {
1157                    return None;
1158                }
1159                let n = fp.rows.len();
1160                let y_min = 0.5;
1161                let y_max = n as f64 + 0.5;
1162                let mut x_min = f64::INFINITY;
1163                let mut x_max = f64::NEG_INFINITY;
1164                for row in &fp.rows {
1165                    x_min = x_min.min(row.ci_lower);
1166                    x_max = x_max.max(row.ci_upper);
1167                }
1168                // Include null value in x range so the reference line is visible
1169                if let Some(nv) = fp.null_value {
1170                    x_min = x_min.min(nv);
1171                    x_max = x_max.max(nv);
1172                }
1173                if !x_min.is_finite() {
1174                    return None;
1175                }
1176                Some(((x_min, x_max), (y_min, y_max)))
1177            }
1178            Plot::Scatter3D(_) | Plot::Surface3D(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1179            // Pixel-space plot — returns dummy bounds so Layout gets a valid range.
1180            Plot::Clustermap(_) => Some(((0.0, 1.0), (0.0, 1.0))),
1181            // Pixel-space composite plot; layout supplied internally via render_multiple.
1182            Plot::Joint(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1183            Plot::Raincloud(r) => {
1184                let n = r.groups.len();
1185                if n == 0 {
1186                    return None;
1187                }
1188                let all_vals: Vec<f64> = r
1189                    .groups
1190                    .iter()
1191                    .flat_map(|g| g.values.iter().copied())
1192                    .collect();
1193                if all_vals.is_empty() {
1194                    return None;
1195                }
1196                let data_min = all_vals.iter().cloned().fold(f64::INFINITY, f64::min);
1197                let data_max = all_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1198                let pad = (data_max - data_min) * 0.05 + 0.5;
1199                if r.horizontal {
1200                    Some(((data_min - pad, data_max + pad), (0.5, n as f64 + 0.5)))
1201                } else {
1202                    Some(((0.5, n as f64 + 0.5), (data_min - pad, data_max + pad)))
1203                }
1204            }
1205            Plot::Survival(sp) => {
1206                if sp.groups.is_empty() {
1207                    return None;
1208                }
1209                let t_max = sp
1210                    .groups
1211                    .iter()
1212                    .flat_map(|g| g.times.iter().copied())
1213                    .fold(0.0_f64, f64::max);
1214                if t_max <= 0.0 {
1215                    return None;
1216                }
1217                // y from 0 to 1 with small padding; x from 0 to t_max with padding
1218                Some(((0.0, t_max), (0.0, 1.0)))
1219            }
1220            Plot::Lollipop(lp) => {
1221                if lp.points.is_empty() {
1222                    return None;
1223                }
1224                let mut x_min = f64::INFINITY;
1225                let mut x_max = f64::NEG_INFINITY;
1226                let mut y_min = lp.baseline;
1227                let mut y_max = lp.baseline;
1228                for p in &lp.points {
1229                    x_min = x_min.min(p.x);
1230                    x_max = x_max.max(p.x);
1231                    y_min = y_min.min(p.y);
1232                    y_max = y_max.max(p.y);
1233                }
1234                for d in &lp.domains {
1235                    x_min = x_min.min(d.x_start);
1236                    x_max = x_max.max(d.x_end);
1237                }
1238                if !lp.domains.is_empty() {
1239                    y_min = y_min.min(lp.baseline - lp.domain_height);
1240                }
1241                if !x_min.is_finite() {
1242                    return None;
1243                }
1244                // Pad x so dots at x=0 aren't clipped by the left axis border.
1245                let x_span = (x_max - x_min).max(1.0);
1246                x_min -= x_span * 0.04;
1247                x_max += x_span * 0.04;
1248                Some(((x_min, x_max), (y_min, y_max)))
1249            }
1250            Plot::Roc(_) => Some(((0.0, 1.0), (0.0, 1.0))),
1251            Plot::Pr(_) => Some(((0.0, 1.0), (0.0, 1.0))),
1252            Plot::Slope(s) => {
1253                let n = s.points.len();
1254                if n == 0 {
1255                    return None;
1256                }
1257                let mut x_min = f64::INFINITY;
1258                let mut x_max = f64::NEG_INFINITY;
1259                for p in &s.points {
1260                    x_min = x_min.min(p.before).min(p.after);
1261                    x_max = x_max.max(p.before).max(p.after);
1262                }
1263                if !x_min.is_finite() {
1264                    return None;
1265                }
1266                let pad = (x_max - x_min) * 0.08 + 1e-9;
1267                Some(((x_min - pad, x_max + pad), (0.5, n as f64 + 0.5)))
1268            }
1269            // Pixel-space plot — dummy bounds so auto_from_plots sees it
1270            Plot::Venn(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1271            // Pixel-space plot — dummy bounds so auto_from_plots sees it
1272            Plot::Parallel(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1273            // Pixel-space plot — no axis bounds needed
1274            Plot::Mosaic(_) => None,
1275            Plot::Ecdf(ep) => {
1276                if ep.groups.is_empty() {
1277                    return None;
1278                }
1279                let mut x_min = f64::INFINITY;
1280                let mut x_max = f64::NEG_INFINITY;
1281                for group in &ep.groups {
1282                    for &v in &group.data {
1283                        x_min = x_min.min(v);
1284                        x_max = x_max.max(v);
1285                    }
1286                }
1287                if !x_min.is_finite() {
1288                    return None;
1289                }
1290                Some(((x_min, x_max), (0.0, 1.0)))
1291            }
1292            Plot::QQ(qp) => {
1293                use crate::plot::qq::QQMode;
1294                use crate::render::render_utils::probit;
1295                if qp.groups.is_empty() {
1296                    return None;
1297                }
1298                match qp.mode {
1299                    QQMode::Normal => {
1300                        let n_max = qp.groups.iter().map(|g| g.data.len()).max().unwrap_or(0);
1301                        if n_max == 0 {
1302                            return None;
1303                        }
1304                        let th_min = probit(0.5 / n_max as f64);
1305                        let th_max = probit(1.0 - 0.5 / n_max as f64);
1306                        let mut y_min = f64::INFINITY;
1307                        let mut y_max = f64::NEG_INFINITY;
1308                        for g in &qp.groups {
1309                            for &v in &g.data {
1310                                y_min = y_min.min(v);
1311                                y_max = y_max.max(v);
1312                            }
1313                        }
1314                        if !y_min.is_finite() {
1315                            return None;
1316                        }
1317                        Some(((th_min, th_max), (y_min, y_max)))
1318                    }
1319                    QQMode::Genomic => {
1320                        let n_max = qp.groups.iter().map(|g| g.data.len()).max().unwrap_or(0);
1321                        if n_max == 0 {
1322                            return None;
1323                        }
1324                        let x_max = (2.0 * n_max as f64).log10();
1325                        let mut y_max: f64 = 0.0;
1326                        for g in &qp.groups {
1327                            for &p in &g.data {
1328                                if p > 0.0 && p <= 1.0 {
1329                                    y_max = y_max.max(-p.log10());
1330                                }
1331                            }
1332                        }
1333                        Some(((0.0, x_max), (0.0, y_max)))
1334                    }
1335                }
1336            }
1337            Plot::Hexbin(hb) => {
1338                if hb.x.is_empty() {
1339                    return None;
1340                }
1341                let x0 = hb
1342                    .x_range
1343                    .map(|(lo, _)| lo)
1344                    .unwrap_or_else(|| hb.x.iter().cloned().fold(f64::INFINITY, f64::min));
1345                let x1 = hb
1346                    .x_range
1347                    .map(|(_, hi)| hi)
1348                    .unwrap_or_else(|| hb.x.iter().cloned().fold(f64::NEG_INFINITY, f64::max));
1349                let y0 = hb
1350                    .y_range
1351                    .map(|(lo, _)| lo)
1352                    .unwrap_or_else(|| hb.y.iter().cloned().fold(f64::INFINITY, f64::min));
1353                let y1 = hb
1354                    .y_range
1355                    .map(|(_, hi)| hi)
1356                    .unwrap_or_else(|| hb.y.iter().cloned().fold(f64::NEG_INFINITY, f64::max));
1357                Some(((x0, x1), (y0, y1)))
1358            }
1359            // Pixel-space plots — dummy bounds so auto_from_plots sees them
1360            Plot::Treemap(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1361            Plot::Sunburst(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1362            Plot::Funnel(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1363            Plot::Rose(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1364            Plot::Calendar(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1365            Plot::Bump(bp) => {
1366                let n = bp.total_series_count();
1367                let n_time = bp.n_time_points();
1368                if n == 0 || n_time == 0 {
1369                    Some(((0.5, 1.5), (0.5, 1.5)))
1370                } else {
1371                    Some(((0.5, n_time as f64 + 0.5), (0.5, n as f64 + 0.5)))
1372                }
1373            }
1374            Plot::Pyramid(pp) => {
1375                let n = pp.n_groups();
1376                if n == 0 {
1377                    return None;
1378                }
1379                let max_val = pp.max_value();
1380                if max_val <= 0.0 {
1381                    return None;
1382                }
1383                Some(((-max_val, max_val), (0.5, n as f64 + 0.5)))
1384            }
1385            Plot::Waffle(_) => Some(((-1.0, 1.0), (-1.0, 1.0))),
1386            Plot::Horizon(hp) => {
1387                let n = hp.series.len();
1388                if n == 0 {
1389                    return None;
1390                }
1391                let (x_min, x_max) = hp.x_range()?;
1392                Some(((x_min, x_max), (0.5, n as f64 + 0.5)))
1393            }
1394            Plot::Gantt(gp) => {
1395                let rows = gp.ordered_display_rows();
1396                let n = rows.len();
1397                if n == 0 {
1398                    return None;
1399                }
1400                let (x_min, x_max) = gp.x_bounds()?;
1401                Some(((x_min, x_max), (0.5, n as f64 + 0.5)))
1402            }
1403            // LegendPlot renders in its own space — no data bounds.
1404            Plot::LegendPlot(_) => None,
1405            // Rendered in pixel space; dummy bounds satisfy Layout::auto_from_plots.
1406            Plot::Text(_) => Some(((0.0, 1.0), (0.0, 1.0))),
1407            Plot::Network(_) => Some(((0.0, 1.0), (0.0, 1.0))),
1408            Plot::Radar(_) => Some(((0.0, 1.0), (0.0, 1.0))),
1409            Plot::Quiver(q) => {
1410                if q.arrows.is_empty() {
1411                    return None;
1412                }
1413                // One pass for scale + origin extent; a second pass for
1414                // endpoint-expanded bounds only when !tight_bounds.
1415                let (scale, x_min_d, x_max_d, y_min_d, y_max_d) =
1416                    q.effective_scale_and_data_extent();
1417                if !x_min_d.is_finite() {
1418                    return None;
1419                }
1420                if q.tight_bounds {
1421                    return Some(((x_min_d, x_max_d), (y_min_d, y_max_d)));
1422                }
1423                let mut x_min = f64::INFINITY;
1424                let mut x_max = f64::NEG_INFINITY;
1425                let mut y_min = f64::INFINITY;
1426                let mut y_max = f64::NEG_INFINITY;
1427                for a in &q.arrows {
1428                    let (tail, tip) = q.endpoints_with_scale(a, scale);
1429                    x_min = x_min.min(tail.0).min(tip.0);
1430                    x_max = x_max.max(tail.0).max(tip.0);
1431                    y_min = y_min.min(tail.1).min(tip.1);
1432                    y_max = y_max.max(tail.1).max(tip.1);
1433                }
1434                if !x_min.is_finite() {
1435                    return None;
1436                }
1437                Some(((x_min, x_max), (y_min, y_max)))
1438            }
1439            Plot::Streamgraph(sg) => {
1440                let geom = sg.compute_geometry()?;
1441                let x_min = sg.x.iter().cloned().fold(f64::INFINITY, f64::min);
1442                let x_max = sg.x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1443                let y_min = geom.baseline.iter().cloned().fold(f64::INFINITY, f64::min);
1444                let y_max = geom
1445                    .uppers
1446                    .last()
1447                    .map(|u| u.iter().cloned().fold(f64::NEG_INFINITY, f64::max))
1448                    .unwrap_or(0.0);
1449                Some(((x_min, x_max), (y_min, y_max)))
1450            }
1451        }
1452    }
1453
1454    /// Rough upper-bound on the number of SVG primitives this plot will emit.
1455    /// Used to pre-allocate the Scene elements vector and avoid repeated reallocs.
1456    pub fn estimated_primitives(&self) -> usize {
1457        match self {
1458            Plot::Scatter(s) => {
1459                let n = s.data.len();
1460                let err = if s
1461                    .data
1462                    .iter()
1463                    .any(|p| p.x_err.is_some() || p.y_err.is_some())
1464                {
1465                    n * 3
1466                } else {
1467                    0
1468                };
1469                n + err + 10
1470            }
1471            Plot::Line(l) => l.data.len() / 10 + 10,
1472            Plot::Series(s) => s.values.len() / 10 + 10,
1473            Plot::Manhattan(m) => m.points.len() + m.spans.len() * 2 + 30,
1474            Plot::Heatmap(h) => {
1475                let cells: usize = h.data.iter().map(|r| r.len()).sum();
1476                (if h.show_values { cells * 2 } else { cells }) + 10
1477            }
1478            Plot::Histogram2d(h) => h.bins.iter().map(|r| r.len()).sum::<usize>() + 10,
1479            Plot::Violin(v) => v.groups.len() * 20 + 10,
1480            Plot::Bar(b) => b.groups.iter().map(|g| g.bars.len()).sum::<usize>() * 2 + 10,
1481            Plot::Histogram(h) => h.bins * 2 + 10,
1482            Plot::Brick(b) => {
1483                let rows = if b.strigar_exp.is_some() {
1484                    b.strigar_exp.as_ref().map_or(0, |e| e.len())
1485                } else {
1486                    b.sequences.len()
1487                };
1488                let avg_cols = b.sequences.first().map_or(10, |s| s.len());
1489                rows * avg_cols + 10
1490            }
1491            Plot::Forest(f) => f.rows.len() * 4 + 5,
1492            Plot::Scatter3D(s) => s.data.len() + 70,
1493            Plot::Surface3D(s) => {
1494                let n = s.nrows().saturating_sub(1) * s.ncols().saturating_sub(1);
1495                n + 70
1496            }
1497            Plot::Clustermap(c) => {
1498                let cells: usize = c.data.iter().map(|r| r.len()).sum();
1499                cells + 500
1500            }
1501            Plot::Joint(jp) => {
1502                jp.groups
1503                    .iter()
1504                    .map(|g| g.scatter.data.len() * 3 + 200)
1505                    .sum::<usize>()
1506                    + 100
1507            }
1508            Plot::Raincloud(r) => {
1509                let total_pts: usize = r.groups.iter().map(|g| g.values.len()).sum();
1510                r.groups.len() * 30 + total_pts + 10
1511            }
1512            Plot::Survival(sp) => sp.groups.iter().map(|g| g.times.len() * 3 + 20).sum(),
1513            Plot::Lollipop(lp) => lp.points.len() * 2 + lp.domains.len() * 2 + 5,
1514            Plot::Roc(r) => {
1515                r.groups
1516                    .iter()
1517                    .map(|g| g.raw_predictions.as_ref().map(|p| p.len()).unwrap_or(100) * 2 + 50)
1518                    .sum::<usize>()
1519                    + 10
1520            }
1521            Plot::Pr(r) => {
1522                r.groups
1523                    .iter()
1524                    .map(|g| g.raw_predictions.as_ref().map(|p| p.len()).unwrap_or(100) * 2 + 50)
1525                    .sum::<usize>()
1526                    + 10
1527            }
1528            Plot::Slope(s) => s.points.len() * 5 + 10,
1529            Plot::Venn(v) => v.sets.len() * 10 + 50,
1530            Plot::Parallel(p) => p.rows.len() + p.axis_names.len() * 10 + 50,
1531            Plot::Mosaic(mp) => {
1532                let nc = mp.effective_col_order().len();
1533                let nr = mp.effective_row_order().len();
1534                nc * nr * 2 + nc + nr + 30
1535            }
1536            Plot::Ecdf(ep) => {
1537                let n: usize = ep.groups.iter().map(|g| g.data.len()).sum();
1538                let band = if ep.show_confidence_band { n * 4 } else { 0 };
1539                let rug = if ep.show_rug { n } else { 0 };
1540                ep.groups.len() * 2 + n * 2 + band + rug + 20
1541            }
1542            Plot::QQ(qp) => {
1543                let n: usize = qp.groups.iter().map(|g| g.data.len()).sum();
1544                let band = if qp.show_ci_band { n * 4 } else { 0 };
1545                qp.groups.len() * 2 + n + band + 20
1546            }
1547            Plot::Network(n) => n.nodes.len() * 2 + n.edges.len() * 3 + 20,
1548            Plot::Radar(r) => {
1549                r.series.len() * (r.axes.len() + 2) + r.grid_lines * r.axes.len() + 30
1550            }
1551            Plot::Streamgraph(sg) => sg.series.len() * (sg.x.len() * 3 + 2) + 20,
1552            Plot::Hexbin(hb) => hb.n_bins * hb.n_bins / 2,
1553            Plot::Treemap(tm) => tm.node_count() * 3 + 10,
1554            Plot::Sunburst(sb) => sb.node_count() * 2 + 10,
1555            Plot::Bump(bp) => bp.total_series_count() * bp.n_time_points() * 3 + 20,
1556            Plot::Funnel(fp) => fp.stage_count() * 6 + 20,
1557            Plot::Rose(rp) => {
1558                rp.n_sectors() * rp.series.len().max(1) * 2
1559                    + rp.grid_lines * 2
1560                    + rp.n_sectors()
1561                    + 20
1562            }
1563            Plot::Calendar(cp) => cp.data.len() + 100,
1564            Plot::Pyramid(pp) => pp.series.len() * pp.n_groups() * 4 + 20,
1565            Plot::Waffle(wp) => wp.rows * wp.cols + 10,
1566            Plot::Horizon(hp) => {
1567                let n = hp.series.len();
1568                let pts_per_series = hp.series.first().map(|s| s.x.len()).unwrap_or(100);
1569                n * hp.n_bands * 2 * pts_per_series / 10 + 20
1570            }
1571            Plot::Gantt(gp) => gp.tasks.len() * 5 + 20,
1572            Plot::Text(tp) => tp.body.lines().count() * 2 + 10,
1573            Plot::Quiver(q) => q.arrows.len() * 2 + 10,
1574            _ => 100,
1575        }
1576    }
1577
1578    /// `bw_mode` forces every colorbar's colormap to `ColorMap::Grayscale`, matching
1579    /// the data fills the renderer draws when BW mode is on (see e.g. `add_heatmap`),
1580    /// so the colorbar and the data it labels stay in sync. Exception: `Scatter3D`'s
1581    /// colorbar is suppressed entirely (`None`) in BW mode, because its point fills
1582    /// already ignore `z_colormap` in favor of a flat marker color — showing a
1583    /// colorbar for a mapping the renderer no longer uses would be misleading.
1584    pub fn colorbar_info(&self, bw_mode: bool) -> Option<ColorBarInfo> {
1585        let cmap_of = |c: &ColorMap| -> ColorMap {
1586            if bw_mode {
1587                ColorMap::Grayscale
1588            } else {
1589                c.clone()
1590            }
1591        };
1592        match self {
1593            Plot::Heatmap(hm) => {
1594                let min = hm
1595                    .data
1596                    .iter()
1597                    .flatten()
1598                    .cloned()
1599                    .fold(f64::INFINITY, f64::min);
1600                let max = hm
1601                    .data
1602                    .iter()
1603                    .flatten()
1604                    .cloned()
1605                    .fold(f64::NEG_INFINITY, f64::max);
1606                colorbar_linear(&cmap_of(&hm.color_map), min, max, None)
1607            }
1608            Plot::Histogram2d(h2d) => {
1609                let max_count = h2d.bins.iter().flatten().copied().max().unwrap_or(1) as f64;
1610                let cmap = cmap_of(&h2d.color_map);
1611                let log_scale = h2d.log_count;
1612                if log_scale {
1613                    // Colorbar in log₁₀ space: ticks at integer powers of 10 labelled
1614                    // with the actual count value so users can read off "this color = N cells".
1615                    // Positions live in log space; the raw counts are supplied as values so
1616                    // `add_colorbar_at` formats them through `with_colorbar_tick_format`.
1617                    let log_max = (max_count + 1.0).log10();
1618                    let tick_values: Vec<(f64, f64)> = {
1619                        let mut v = vec![(0.0_f64, 0.0_f64)];
1620                        let mut k = 0u32;
1621                        loop {
1622                            let count = 10_f64.powi(k as i32);
1623                            if count > max_count {
1624                                break;
1625                            }
1626                            let pos = (count + 1.0).log10();
1627                            v.push((pos, count));
1628                            k += 1;
1629                        }
1630                        // Always include max_count at the top
1631                        v.push((log_max, max_count));
1632                        v.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9);
1633                        v
1634                    };
1635                    Some(ColorBarInfo {
1636                        map_fn: Arc::new(move |t| {
1637                            // t is a log₁₀ value in [0, log_max]
1638                            cmap.map((t / log_max).clamp(0.0, 1.0))
1639                        }),
1640                        min_value: 0.0,
1641                        max_value: log_max,
1642                        label: Some("log\u{2081}\u{2080}(Count + 1)".to_string()),
1643                        tick_labels: None,
1644                        tick_values: Some(tick_values),
1645                    })
1646                } else {
1647                    Some(ColorBarInfo {
1648                        map_fn: Arc::new(move |t| cmap.map((t / max_count).clamp(0.0, 1.0))),
1649                        min_value: 0.0,
1650                        max_value: max_count,
1651                        label: Some("Count".to_string()),
1652                        tick_labels: None,
1653                        tick_values: None,
1654                    })
1655                }
1656            }
1657            Plot::DotPlot(dp) => {
1658                let label = dp.color_legend_label.clone()?;
1659                let (min, max) = dp.color_range.unwrap_or_else(|| dp.color_extent());
1660                colorbar_linear(&cmap_of(&dp.color_map), min, max, Some(label))
1661            }
1662            Plot::DicePlot(dp) => {
1663                let label = dp.fill_legend_label.clone()?;
1664                let (min, max) = dp.fill_range.unwrap_or_else(|| dp.fill_extent());
1665                colorbar_linear(&cmap_of(&dp.color_map), min, max, Some(label))
1666            }
1667            Plot::Contour(cp) => {
1668                if !cp.filled {
1669                    return None;
1670                }
1671                let (z_min, z_max) = cp.z_range();
1672                colorbar_linear(
1673                    &cmap_of(&cp.color_map),
1674                    z_min,
1675                    z_max,
1676                    cp.legend_label.clone(),
1677                )
1678            }
1679            Plot::Clustermap(cm) => {
1680                if cm.data.is_empty() || cm.data.iter().all(|r| r.is_empty()) {
1681                    return None;
1682                }
1683                let min = cm
1684                    .data
1685                    .iter()
1686                    .flatten()
1687                    .cloned()
1688                    .fold(f64::INFINITY, f64::min);
1689                let max = cm
1690                    .data
1691                    .iter()
1692                    .flatten()
1693                    .cloned()
1694                    .fold(f64::NEG_INFINITY, f64::max);
1695                colorbar_linear(&cmap_of(&cm.color_map), min, max, cm.legend_label.clone())
1696            }
1697            Plot::Surface3D(s) => colorbar_from_z(
1698                &cmap_of(s.z_colormap.as_ref()?),
1699                s.data_ranges()?,
1700                s.box3d.z_label.clone(),
1701            ),
1702            Plot::Scatter3D(s) => {
1703                if bw_mode {
1704                    // Points render as a flat marker color in BW mode (see add_scatter3d),
1705                    // ignoring z_colormap entirely — a colorbar for an unused mapping
1706                    // would be misleading, so suppress it.
1707                    return None;
1708                }
1709                colorbar_from_z(
1710                    s.z_colormap.as_ref()?,
1711                    s.data_ranges()?,
1712                    s.box3d.z_label.clone(),
1713                )
1714            }
1715            Plot::Quiver(q) => {
1716                let cmap = cmap_of(q.color_map.as_ref()?);
1717                let (min, max) = q.color_range.unwrap_or_else(|| q.magnitude_extent());
1718                colorbar_linear(&cmap, min, max, q.color_legend_label.clone())
1719            }
1720            // Hexbin draws its own colorbar inside add_hexbin (values are only known
1721            // after binning).  Return None here so the generic colorbar loop in
1722            // render_multiple does not attempt to draw a second, placeholder bar.
1723            // The layout margin is set via the explicit has_colorbar check in layout.rs.
1724            _ => None,
1725        }
1726    }
1727}