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