Skip to main content

chart_review/
chart_review.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Comprehensive chart review: every chart type with variations to verify the audit fixes.
3
4use std::fmt::Write;
5
6use esoc_chart::express::{
7    area, bar, boxplot, grouped_bar, heatmap, histogram, line, pie_labeled, scatter, stacked_bar,
8};
9use esoc_chart::grammar::annotation::Annotation;
10use esoc_chart::grammar::chart::Chart;
11use esoc_chart::grammar::coord::CoordSystem;
12use esoc_chart::grammar::facet::{Facet, FacetScales};
13use esoc_chart::grammar::layer::{Layer, MarkType};
14use esoc_chart::grammar::position::Position;
15use esoc_chart::grammar::stat::Stat;
16use esoc_chart::new_theme::NewTheme;
17
18fn main() -> esoc_chart::error::Result<()> {
19    // ── Simple RNG for reproducible data ─────────────────────────────
20    struct Rng(u64);
21    impl Rng {
22        fn uniform(&mut self) -> f64 {
23            self.0 = self
24                .0
25                .wrapping_mul(6_364_136_223_846_793_005)
26                .wrapping_add(1);
27            (self.0 >> 11) as f64 / (1u64 << 53) as f64
28        }
29        fn normal(&mut self) -> f64 {
30            let u1 = self.uniform().max(1e-15);
31            let u2 = self.uniform();
32            (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
33        }
34    }
35    let mut sections: Vec<(&str, String)> = Vec::new();
36    let mut rng = Rng(42);
37
38    // ═══════════════════════════════════════════════════════════════════
39    // SCATTER PLOTS
40    // ═══════════════════════════════════════════════════════════════════
41
42    // Basic scatter
43    {
44        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
45        let y = vec![2.3, 4.1, 3.0, 5.8, 4.9, 7.2, 6.5, 8.1];
46        let svg = scatter(&x, &y)
47            .title("Basic Scatter")
48            .x_label("X")
49            .y_label("Y")
50            .size(500.0, 350.0)
51            .to_svg()?;
52        sections.push(("Scatter – Basic", svg));
53    }
54
55    // Scatter with categories + legend
56    {
57        let x = vec![
58            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
59        ];
60        let y = vec![2.3, 4.1, 3.0, 5.8, 4.9, 7.2, 6.5, 8.1, 3.5, 5.2, 6.8, 7.9];
61        let cats = vec!["A", "B", "C", "A", "B", "C", "A", "B", "C", "A", "B", "C"];
62        let svg = scatter(&x, &y)
63            .color_by(&cats)
64            .title("Scatter – 3 Categories")
65            .x_label("Feature 1")
66            .y_label("Feature 2")
67            .size(500.0, 350.0)
68            .to_svg()?;
69        sections.push(("Scatter – Categories", svg));
70    }
71
72    // Dense scatter (auto opacity)
73    {
74        let n = 400;
75        let x: Vec<f64> = (0..n).map(|_| rng.normal() * 3.0 + 5.0).collect();
76        let y: Vec<f64> = x.iter().map(|&xi| xi * 0.8 + rng.normal() * 2.0).collect();
77        let svg = scatter(&x, &y)
78            .title("Dense Scatter (n=400, auto-opacity)")
79            .x_label("Feature A")
80            .y_label("Feature B")
81            .size(500.0, 350.0)
82            .to_svg()?;
83        sections.push(("Scatter – Dense", svg));
84    }
85
86    // Single point scatter (edge case)
87    {
88        let svg = scatter(&[5.0], &[10.0])
89            .title("Single Point")
90            .size(400.0, 300.0)
91            .to_svg()?;
92        sections.push(("Scatter – Single Point", svg));
93    }
94
95    // Scatter with description (accessibility)
96    {
97        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
98        let y = vec![2.0, 4.0, 3.0, 5.0, 4.5];
99        let chart = Chart::new()
100            .layer(Layer::new(MarkType::Point).with_x(x).with_y(y))
101            .title("Accessible Chart")
102            .description("A scatter plot showing 5 data points with an upward trend")
103            .size(500.0, 350.0);
104        let svg = chart.to_svg()?;
105        // Verify SVG has role="img", <title>, <desc>
106        assert!(svg.contains(r#"role="img""#));
107        assert!(svg.contains("<title>"));
108        assert!(svg.contains("<desc>"));
109        sections.push(("Scatter – Accessibility", svg));
110    }
111
112    // ═══════════════════════════════════════════════════════════════════
113    // LINE CHARTS
114    // ═══════════════════════════════════════════════════════════════════
115
116    // Basic line
117    {
118        let x: Vec<f64> = (0..20).map(|i| f64::from(i) * 0.5).collect();
119        let y: Vec<f64> = x.iter().map(|&v| (v * 0.8).sin() * 3.0 + v).collect();
120        let svg = line(&x, &y)
121            .title("Line Chart")
122            .x_label("Time")
123            .y_label("Value")
124            .size(500.0, 350.0)
125            .to_svg()?;
126        sections.push(("Line – Basic", svg));
127    }
128
129    // Multi-line with legend
130    {
131        let x: Vec<f64> = (0..30).map(|i| f64::from(i) * 0.5).collect();
132        let y1: Vec<f64> = x.iter().map(|&v| (v * 0.4).sin() * 5.0 + 10.0).collect();
133        let y2: Vec<f64> = x.iter().map(|&v| (v * 0.4).cos() * 4.0 + 12.0).collect();
134        let y3: Vec<f64> = x.iter().map(|&v| v * 0.5 + 5.0).collect();
135
136        let chart = Chart::new()
137            .layer(
138                Layer::new(MarkType::Line)
139                    .with_x(x.clone())
140                    .with_y(y1)
141                    .with_label("sin"),
142            )
143            .layer(
144                Layer::new(MarkType::Line)
145                    .with_x(x.clone())
146                    .with_y(y2)
147                    .with_label("cos"),
148            )
149            .layer(
150                Layer::new(MarkType::Line)
151                    .with_x(x)
152                    .with_y(y3)
153                    .with_label("linear"),
154            )
155            .title("Multi-Line with Legend")
156            .x_label("Time")
157            .y_label("Signal")
158            .size(500.0, 350.0);
159        sections.push(("Line – Multi-series", chart.to_svg()?));
160    }
161
162    // LOESS smooth overlay
163    {
164        let x: Vec<f64> = (0..40).map(|i| f64::from(i) * 0.25).collect();
165        let y: Vec<f64> = x
166            .iter()
167            .map(|&v| (v * 0.5).sin() * 3.0 + rng.normal() * 0.8)
168            .collect();
169        let chart = Chart::new()
170            .layer(
171                Layer::new(MarkType::Point)
172                    .with_x(x.clone())
173                    .with_y(y.clone())
174                    .with_label("Raw"),
175            )
176            .layer(
177                Layer::new(MarkType::Line)
178                    .with_x(x)
179                    .with_y(y)
180                    .stat(Stat::Smooth { bandwidth: 0.3 })
181                    .with_label("LOESS"),
182            )
183            .title("LOESS Smoothing")
184            .x_label("X")
185            .y_label("Y")
186            .size(500.0, 350.0);
187        sections.push(("Line – LOESS Overlay", chart.to_svg()?));
188    }
189
190    // ═══════════════════════════════════════════════════════════════════
191    // BAR CHARTS
192    // ═══════════════════════════════════════════════════════════════════
193
194    // Basic bar (no legend expected)
195    {
196        let cats = vec!["Rust", "Python", "Go", "Java", "C++"];
197        let vals = vec![42.0, 35.0, 28.0, 22.0, 18.0];
198        let svg = bar(&cats, &vals)
199            .title("Bar Chart (no legend)")
200            .x_label("Language")
201            .y_label("Score")
202            .size(500.0, 350.0)
203            .to_svg()?;
204        sections.push(("Bar – Basic", svg));
205    }
206
207    // Bar with many categories (label rotation)
208    {
209        let cats: Vec<String> = (0..15).map(|i| format!("Category {}", i + 1)).collect();
210        let vals: Vec<f64> = (0..15)
211            .map(|i| (f64::from(i) * 3.7 + 5.0) % 30.0 + 5.0)
212            .collect();
213        let cat_refs: Vec<&str> = cats.iter().map(|s| s.as_str()).collect();
214        let svg = bar(&cat_refs, &vals)
215            .title("Bar – Label Rotation")
216            .x_label("Category")
217            .y_label("Value")
218            .size(600.0, 350.0)
219            .to_svg()?;
220        sections.push(("Bar – Rotated Labels", svg));
221    }
222
223    // Horizontal bar (flipped)
224    {
225        let chart = Chart::new()
226            .layer(
227                Layer::new(MarkType::Bar)
228                    .with_x(vec![0.0, 1.0, 2.0, 3.0, 4.0])
229                    .with_y(vec![42.0, 35.0, 28.0, 22.0, 18.0])
230                    .with_categories(vec![
231                        "Rust".into(),
232                        "Python".into(),
233                        "Go".into(),
234                        "Java".into(),
235                        "C++".into(),
236                    ]),
237            )
238            .coord(CoordSystem::Flipped)
239            .title("Horizontal Bars")
240            .x_label("Score")
241            .y_label("Language")
242            .size(500.0, 350.0);
243        sections.push(("Bar – Horizontal", chart.to_svg()?));
244    }
245
246    // Grouped bar
247    {
248        let cats = vec![
249            "Q1", "Q2", "Q3", "Q4", "Q1", "Q2", "Q3", "Q4", "Q1", "Q2", "Q3", "Q4",
250        ];
251        let groups = vec![
252            "2023", "2023", "2023", "2023", "2024", "2024", "2024", "2024", "2025", "2025", "2025",
253            "2025",
254        ];
255        let vals = vec![
256            10.0, 14.0, 18.0, 12.0, 12.0, 18.0, 22.0, 15.0, 14.0, 20.0, 28.0, 19.0,
257        ];
258        let svg = grouped_bar(&cats, &groups, &vals)
259            .title("Grouped Bar – 3 Series")
260            .x_label("Quarter")
261            .y_label("Revenue ($M)")
262            .size(550.0, 350.0)
263            .to_svg()?;
264        sections.push(("Bar – Grouped", svg));
265    }
266
267    // Stacked bar
268    {
269        let cats = vec!["Q1", "Q2", "Q3", "Q4", "Q1", "Q2", "Q3", "Q4"];
270        let groups = vec![
271            "Product", "Product", "Product", "Product", "Service", "Service", "Service", "Service",
272        ];
273        let vals = vec![10.0, 15.0, 20.0, 18.0, 5.0, 8.0, 12.0, 10.0];
274        let svg = stacked_bar(&cats, &groups, &vals)
275            .title("Stacked Bar")
276            .x_label("Quarter")
277            .y_label("Revenue ($M)")
278            .size(500.0, 350.0)
279            .to_svg()?;
280        sections.push(("Bar – Stacked", svg));
281    }
282
283    // Stacked bar with sparse groups (tests key-based stacking fix)
284    {
285        // Group A only has Q1,Q2; Group B has Q2,Q3,Q4 — sparse overlap
286        let cats = vec!["Q1", "Q2", "Q2", "Q3", "Q4"];
287        let groups = vec!["Alpha", "Alpha", "Beta", "Beta", "Beta"];
288        let vals = vec![10.0, 20.0, 15.0, 25.0, 12.0];
289        let svg = stacked_bar(&cats, &groups, &vals)
290            .title("Stacked – Sparse Groups")
291            .x_label("Quarter")
292            .y_label("Value")
293            .size(500.0, 350.0)
294            .to_svg()?;
295        sections.push(("Bar – Sparse Stacked", svg));
296    }
297
298    // Stacked bar with mixed positive/negative (diverging stack)
299    {
300        let chart = Chart::new()
301            .layer(
302                Layer::new(MarkType::Bar)
303                    .with_x(vec![0.0, 1.0, 2.0, 3.0])
304                    .with_y(vec![10.0, 15.0, 12.0, 18.0])
305                    .with_label("Revenue")
306                    .position(Position::Stack),
307            )
308            .layer(
309                Layer::new(MarkType::Bar)
310                    .with_x(vec![0.0, 1.0, 2.0, 3.0])
311                    .with_y(vec![-4.0, -8.0, -5.0, -6.0])
312                    .with_label("Costs")
313                    .position(Position::Stack),
314            )
315            .title("Diverging Stack (+/-)")
316            .x_label("Period")
317            .y_label("Net Change")
318            .size(500.0, 350.0);
319        sections.push(("Bar – Diverging Stack", chart.to_svg()?));
320    }
321
322    // ═══════════════════════════════════════════════════════════════════
323    // HISTOGRAM
324    // ═══════════════════════════════════════════════════════════════════
325
326    {
327        let data: Vec<f64> = (0..500).map(|_| rng.normal() * 2.0 + 10.0).collect();
328        let svg = histogram(&data)
329            .bins(25)
330            .title("Histogram (n=500, 25 bins)")
331            .x_label("Value")
332            .y_label("Count")
333            .size(500.0, 350.0)
334            .to_svg()?;
335        sections.push(("Histogram", svg));
336    }
337
338    // ═══════════════════════════════════════════════════════════════════
339    // AREA CHARTS
340    // ═══════════════════════════════════════════════════════════════════
341
342    {
343        let x: Vec<f64> = (0..30).map(f64::from).collect();
344        let y: Vec<f64> = x
345            .iter()
346            .map(|&v| (v * 0.3).sin().abs() * 20.0 + 5.0)
347            .collect();
348        let svg = area(&x, &y)
349            .title("Area Chart")
350            .x_label("Day")
351            .y_label("Traffic")
352            .size(500.0, 350.0)
353            .to_svg()?;
354        sections.push(("Area – Basic", svg));
355    }
356
357    // Stacked area
358    {
359        let x: Vec<f64> = (0..20).map(f64::from).collect();
360        let y1: Vec<f64> = x
361            .iter()
362            .map(|&v| (v * 0.3).sin().abs() * 10.0 + 5.0)
363            .collect();
364        let y2: Vec<f64> = x
365            .iter()
366            .map(|&v| (v * 0.2).cos().abs() * 8.0 + 3.0)
367            .collect();
368        let chart = Chart::new()
369            .layer(
370                Layer::new(MarkType::Area)
371                    .with_x(x.clone())
372                    .with_y(y1)
373                    .with_label("Direct")
374                    .position(Position::Stack),
375            )
376            .layer(
377                Layer::new(MarkType::Area)
378                    .with_x(x)
379                    .with_y(y2)
380                    .with_label("Referral")
381                    .position(Position::Stack),
382            )
383            .title("Stacked Area")
384            .x_label("Week")
385            .y_label("Visits")
386            .size(500.0, 350.0);
387        sections.push(("Area – Stacked", chart.to_svg()?));
388    }
389
390    // ═══════════════════════════════════════════════════════════════════
391    // PIE / DONUT
392    // ═══════════════════════════════════════════════════════════════════
393
394    {
395        let vals = vec![35.0, 25.0, 20.0, 15.0, 5.0];
396        let labels = vec!["Chrome", "Firefox", "Safari", "Edge", "Other"];
397        let svg = pie_labeled(&labels, &vals)
398            .title("Pie Chart")
399            .size(400.0, 400.0)
400            .to_svg()?;
401        sections.push(("Pie", svg));
402    }
403
404    {
405        let vals = vec![60.0, 25.0, 15.0];
406        let labels = vec!["Pass", "Warn", "Fail"];
407        let svg = pie_labeled(&labels, &vals)
408            .donut(0.55)
409            .title("Donut Chart")
410            .size(400.0, 400.0)
411            .to_svg()?;
412        sections.push(("Donut", svg));
413    }
414
415    // ═══════════════════════════════════════════════════════════════════
416    // BOX PLOT
417    // ═══════════════════════════════════════════════════════════════════
418
419    {
420        let mut cats = Vec::new();
421        let mut vals = Vec::new();
422        for (label, base, spread) in &[
423            ("Control", 50.0, 15.0),
424            ("Drug A", 65.0, 10.0),
425            ("Drug B", 70.0, 20.0),
426        ] {
427            for _ in 0..40 {
428                vals.push(base + (rng.uniform() - 0.5) * spread * 2.0);
429                cats.push(*label);
430            }
431            // Add outlier
432            vals.push(base + spread * 4.0);
433            cats.push(*label);
434        }
435        let svg = boxplot(&cats, &vals)
436            .title("Box Plot with Outliers")
437            .x_label("Treatment")
438            .y_label("Response")
439            .size(500.0, 350.0)
440            .to_svg()?;
441        sections.push(("Box Plot", svg));
442    }
443
444    // ═══════════════════════════════════════════════════════════════════
445    // HEATMAPS
446    // ═══════════════════════════════════════════════════════════════════
447
448    // Basic heatmap with annotations + gradient legend
449    {
450        let data = vec![
451            vec![1.0, 2.0, 3.0, 4.0, 5.0],
452            vec![5.0, 4.0, 3.0, 2.0, 1.0],
453            vec![2.0, 8.0, 6.0, 4.0, 2.0],
454            vec![3.0, 3.0, 9.0, 3.0, 3.0],
455        ];
456        let svg = heatmap(data)
457            .annotate()
458            .with_row_labels(&["A", "B", "C", "D"])
459            .with_col_labels(&["v1", "v2", "v3", "v4", "v5"])
460            .title("Heatmap (annotated + gradient legend)")
461            .x_label("Variable")
462            .y_label("Group")
463            .size(500.0, 400.0)
464            .to_svg()?;
465        sections.push(("Heatmap – Annotated", svg));
466    }
467
468    // Confusion matrix
469    {
470        let data = vec![
471            vec![45.0, 3.0, 2.0],
472            vec![1.0, 40.0, 5.0],
473            vec![0.0, 4.0, 50.0],
474        ];
475        let svg = heatmap(data)
476            .annotate()
477            .with_row_labels(&["Cat", "Dog", "Bird"])
478            .with_col_labels(&["Cat", "Dog", "Bird"])
479            .title("Confusion Matrix")
480            .x_label("Predicted")
481            .y_label("Actual")
482            .size(400.0, 400.0)
483            .to_svg()?;
484        sections.push(("Heatmap – Confusion Matrix", svg));
485    }
486
487    // Heatmap with custom color scale
488    {
489        let data = vec![
490            vec![0.0, 0.3, 0.7, 1.0],
491            vec![0.2, 0.5, 0.8, 0.9],
492            vec![0.1, 0.4, 0.6, 0.95],
493        ];
494        let mut theme = NewTheme::light();
495        theme.color_scale = Some(esoc_color::ColorScale::rdbu());
496        let svg = heatmap(data)
497            .annotate()
498            .title("Heatmap – RdBu Color Scale")
499            .theme(theme)
500            .size(400.0, 350.0)
501            .to_svg()?;
502        sections.push(("Heatmap – Custom Color Scale", svg));
503    }
504
505    // ═══════════════════════════════════════════════════════════════════
506    // FACETED CHARTS (small multiples)
507    // ═══════════════════════════════════════════════════════════════════
508
509    // Faceted scatter
510    {
511        let mut x = Vec::new();
512        let mut y = Vec::new();
513        let mut facets = Vec::new();
514        for panel in &["East", "West", "North", "South"] {
515            for _ in 0..25 {
516                x.push(rng.uniform() * 10.0);
517                y.push(rng.uniform() * 10.0);
518                facets.push(*panel);
519            }
520        }
521        let svg = scatter(&x, &y)
522            .facet_wrap(&facets, 2)
523            .title("Faceted Scatter (2 cols)")
524            .x_label("X")
525            .y_label("Y")
526            .size(550.0, 450.0)
527            .to_svg()?;
528        sections.push(("Facet – Scatter", svg));
529    }
530
531    // Faceted scatter with categories + legend (tests faceted legend fix)
532    {
533        let mut x = Vec::new();
534        let mut y = Vec::new();
535        let mut cats = Vec::new();
536        let mut facets = Vec::new();
537        for panel in &["Male", "Female"] {
538            for cat in &["Young", "Old"] {
539                for _ in 0..12 {
540                    x.push(rng.uniform() * 10.0);
541                    y.push(rng.uniform() * 10.0);
542                    cats.push(*cat);
543                    facets.push(*panel);
544                }
545            }
546        }
547        let chart = Chart::new()
548            .layer(
549                Layer::new(MarkType::Point)
550                    .with_x(x)
551                    .with_y(y)
552                    .with_categories(cats.iter().map(|s| s.to_string()).collect())
553                    .with_facet_values(facets.iter().map(|s| s.to_string()).collect()),
554            )
555            .facet(Facet::Wrap { ncol: 2 })
556            .title("Faceted + Categories + Legend")
557            .x_label("X")
558            .y_label("Y")
559            .size(550.0, 350.0);
560        sections.push(("Facet – With Legend", chart.to_svg()?));
561    }
562
563    // Faceted with FreeY scales (tests FreeY fix: shared X, free Y)
564    {
565        let mut x = Vec::new();
566        let mut y = Vec::new();
567        let mut facets = Vec::new();
568        // Panel A: small values; Panel B: large values
569        for _ in 0..20 {
570            x.push(rng.uniform() * 10.0);
571            y.push(rng.uniform() * 5.0);
572            facets.push("Small Range");
573        }
574        for _ in 0..20 {
575            x.push(rng.uniform() * 10.0);
576            y.push(rng.uniform() * 500.0);
577            facets.push("Large Range");
578        }
579        let chart = Chart::new()
580            .layer(
581                Layer::new(MarkType::Point)
582                    .with_x(x)
583                    .with_y(y)
584                    .with_facet_values(facets.iter().map(|s| s.to_string()).collect()),
585            )
586            .facet(Facet::Wrap { ncol: 2 })
587            .facet_scales(FacetScales::FreeY)
588            .title("FreeY Scales (shared X, free Y)")
589            .x_label("X")
590            .y_label("Y")
591            .size(550.0, 350.0);
592        sections.push(("Facet – FreeY", chart.to_svg()?));
593    }
594
595    // ═══════════════════════════════════════════════════════════════════
596    // ANNOTATIONS
597    // ═══════════════════════════════════════════════════════════════════
598
599    {
600        let x: Vec<f64> = (0..20).map(f64::from).collect();
601        let y: Vec<f64> = x.iter().map(|&v| v * 1.5 + rng.normal() * 3.0).collect();
602        let chart = scatter(&x, &y)
603            .title("Annotations Demo")
604            .x_label("X")
605            .y_label("Y")
606            .size(500.0, 350.0)
607            .build()
608            .annotate(Annotation::hline(15.0).with_label("Target"))
609            .annotate(Annotation::vline(10.0).with_label("Midpoint"))
610            .annotate(Annotation::band(10.0, 20.0).with_label("Peak zone"));
611        sections.push(("Annotations", chart.to_svg()?));
612    }
613
614    // ═══════════════════════════════════════════════════════════════════
615    // SUBTITLE, CAPTION, LINE+SCATTER OVERLAY
616    // ═══════════════════════════════════════════════════════════════════
617
618    {
619        let x: Vec<f64> = (0..10).map(f64::from).collect();
620        let y_data: Vec<f64> = vec![2.1, 3.8, 3.2, 5.5, 4.8, 7.1, 6.3, 8.0, 7.5, 9.2];
621        let y_trend: Vec<f64> = x.iter().map(|&v| v * 0.8 + 2.0).collect();
622        let chart = Chart::new()
623            .layer(
624                Layer::new(MarkType::Point)
625                    .with_x(x.clone())
626                    .with_y(y_data)
627                    .with_label("Data"),
628            )
629            .layer(
630                Layer::new(MarkType::Line)
631                    .with_x(x)
632                    .with_y(y_trend)
633                    .with_label("Trend"),
634            )
635            .title("Revenue Trend")
636            .subtitle("H1 2026 with linear fit")
637            .caption("Source: internal CRM data")
638            .x_label("Month")
639            .y_label("Revenue ($K)")
640            .size(500.0, 380.0);
641        sections.push(("Line + Scatter + Subtitle/Caption", chart.to_svg()?));
642    }
643
644    // ═══════════════════════════════════════════════════════════════════
645    // DARK THEME
646    // ═══════════════════════════════════════════════════════════════════
647
648    {
649        let x: Vec<f64> = (0..30).map(|i| f64::from(i) * 0.5).collect();
650        let y: Vec<f64> = x.iter().map(|&v| (v * 0.3).sin() * 5.0 + 8.0).collect();
651        let svg = line(&x, &y)
652            .title("Dark Theme")
653            .x_label("Time")
654            .y_label("Value")
655            .theme(NewTheme::dark())
656            .size(500.0, 350.0)
657            .to_svg()?;
658        sections.push(("Theme – Dark", svg));
659    }
660
661    // ═══════════════════════════════════════════════════════════════════
662    // PUBLICATION THEME
663    // ═══════════════════════════════════════════════════════════════════
664
665    {
666        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
667        let y = vec![2.3, 4.1, 3.0, 5.8, 4.9, 7.2, 6.5, 8.1];
668        let svg = scatter(&x, &y)
669            .title("Publication Theme (no grid, serif)")
670            .x_label("X")
671            .y_label("Y")
672            .theme(NewTheme::publication())
673            .size(500.0, 350.0)
674            .to_svg()?;
675        sections.push(("Theme – Publication", svg));
676    }
677
678    // ═══════════════════════════════════════════════════════════════════
679    // BUILD HTML
680    // ═══════════════════════════════════════════════════════════════════
681
682    let mut html = String::from(
683        r#"<!DOCTYPE html>
684<html lang="en">
685<head>
686<meta charset="UTF-8">
687<meta name="viewport" content="width=device-width, initial-scale=1.0">
688<title>esoc-chart Review — All Chart Types</title>
689<style>
690  * { margin: 0; padding: 0; box-sizing: border-box; }
691  body { font-family: system-ui, -apple-system, sans-serif; background: #f0f0f4; color: #333; }
692  header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: white; padding: 2.5rem 2rem; text-align: center; }
693  header h1 { font-size: 2rem; font-weight: 300; letter-spacing: 0.02em; }
694  header p { margin-top: 0.5rem; opacity: 0.7; font-size: 0.95rem; }
695  .stats { display: flex; justify-content: center; gap: 2rem; margin-top: 1rem; }
696  .stats span { background: rgba(255,255,255,0.15); padding: 0.3rem 0.8rem; border-radius: 4px; font-size: 0.85rem; }
697  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 1.5rem; padding: 2rem; max-width: 1600px; margin: 0 auto; }
698  .card { background: white; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden; transition: box-shadow 0.2s; }
699  .card:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.12); }
700  .card h2 { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #555; padding: 1rem 1.5rem 0; }
701  .card .chart-wrap { padding: 0.5rem 1rem 0.75rem; }
702  .card svg { display: block; width: 100%; height: auto; }
703  .card.dark-bg .chart-wrap { background: #1e1e2e; border-radius: 0 0 8px 8px; }
704  .feedback { padding: 0 1rem 1rem; }
705  .feedback textarea { width: 100%; min-height: 50px; border: 1px solid #e0e0e0; border-radius: 4px; padding: 0.5rem; font-family: inherit; font-size: 0.82rem; resize: vertical; }
706  .feedback textarea:focus { outline: none; border-color: #1a1a2e; }
707  .feedback .status { font-size: 0.72rem; color: #aaa; margin-top: 0.2rem; }
708  .actions { padding: 1.5rem 2rem; text-align: center; }
709  .actions button { background: #1a1a2e; color: white; border: none; border-radius: 4px; padding: 0.6rem 1.5rem; font-size: 0.9rem; cursor: pointer; margin: 0 0.5rem; }
710  .actions button:hover { background: #2a2a4e; }
711</style>
712<script>
713  const feedback = {};
714  function loadFeedback() {
715    try { Object.assign(feedback, JSON.parse(localStorage.getItem('chart_review_feedback') || '{}')); } catch {}
716    document.querySelectorAll('.feedback textarea').forEach(ta => {
717      const key = ta.dataset.chart;
718      if (feedback[key]) ta.value = feedback[key];
719    });
720  }
721  function saveFeedback(key, value) {
722    feedback[key] = value;
723    localStorage.setItem('chart_review_feedback', JSON.stringify(feedback));
724  }
725  function exportFeedback() {
726    const blob = new Blob([JSON.stringify(feedback, null, 2)], {type: 'application/json'});
727    const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
728    a.download = 'chart_review_feedback.json'; a.click();
729  }
730  window.addEventListener('DOMContentLoaded', loadFeedback);
731</script>
732</head>
733<body>
734<header>
735  <h1>esoc-chart Review</h1>
736  <p>Comprehensive sample of all chart types &amp; variations after audit fixes</p>
737  <div class="stats">
738"#,
739    );
740
741    writeln!(html, "    <span>{} charts</span>", sections.len()).unwrap();
742    html.push_str("    <span>6 phases of fixes</span>\n");
743    html.push_str("    <span>23 new tests</span>\n");
744    html.push_str("  </div>\n</header>\n<div class=\"grid\">\n");
745
746    for (title, svg) in &sections {
747        let key = title
748            .to_lowercase()
749            .replace([' ', '–', '+', '/', '(', ')'], "_")
750            .replace("__", "_");
751        let dark_class = if title.contains("Dark") {
752            " dark-bg"
753        } else {
754            ""
755        };
756        write!(
757            html,
758            concat!(
759                "<div class=\"card{dark_class}\">\n",
760                "  <h2>{title}</h2>\n",
761                "  <div class=\"chart-wrap\">{svg}</div>\n",
762                "  <div class=\"feedback\">\n",
763                "    <textarea data-chart=\"{key}\" placeholder=\"Notes on {title}…\" ",
764                "oninput=\"saveFeedback('{key}', this.value)\"></textarea>\n",
765                "    <div class=\"status\">Auto-saved</div>\n",
766                "  </div>\n",
767                "</div>\n",
768            ),
769            title = title,
770            svg = svg,
771            key = key,
772            dark_class = dark_class,
773        )
774        .unwrap();
775    }
776
777    html.push_str(concat!(
778        "</div>\n",
779        "<div class=\"actions\">\n",
780        "  <button onclick=\"exportFeedback()\">Export Feedback JSON</button>\n",
781        "</div>\n",
782        "</body>\n</html>\n",
783    ));
784
785    let out_path = "chart_review.html";
786    std::fs::write(out_path, &html).expect("failed to write HTML");
787    println!("Saved {} ({} charts)", out_path, sections.len());
788
789    Ok(())
790}