Skip to main content

faceted/
faceted.rs

1//! Multi-plot example: scale sharing across many plots in a single
2//! composition, layered on top of real nesting.
3//!
4//! Layout: an outer composition wraps a 2×2 inner facet grid `beside` a
5//! column-spanning summary patch.
6//!
7//! ```text
8//!   ┌─────────────┬─────────────┐
9//!   │   q1    q2  │             │
10//!   │             │   summary   │
11//!   │   q3    q4  │             │
12//!   └─────────────┴─────────────┘
13//! ```
14//!
15//! Five plots total — four facets plus the summary. All five bind their
16//! `"x"` channel to the same scale name `"time"`, so a single
17//! `view.update_scale("time", |s| ...)` between renders updates every
18//! panel at once.
19//!
20//! Produces:
21//! - `examples/faceted_1_initial.png` — full x range, all five panels.
22//! - `examples/faceted_2_shared_zoom.png` — shared x narrowed once;
23//!   propagates to every plot.
24//! - `examples/faceted_3_aspect_locked.png` — outer composition gains
25//!   `.aspect(1, 1)`; selective-respect under nesting locks each leaf
26//!   panel to 1:1 while the surrounding row/col tracks absorb slack.
27//!
28//! Renders 1 & 2 also carry **composition-level chrome** — a title,
29//! subtitle, caption, one shared bottom axis title, and a single legend
30//! — set directly on `PlotComposition`. Each spans the whole five-panel
31//! layout rather than repeating per patch.
32
33use hephaestus::backend::vello::VelloRenderer;
34use hephaestus::color::{rgb8, Color};
35use hephaestus::composition::{beside, grid, Composition, Element, Patch};
36use hephaestus::geometry::Size;
37use hephaestus::plot::chrome::axis::{Axis, AxisPlacement};
38use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec};
39use hephaestus::plot::{scale, Plot, PlotComposition, PointGeom};
40use hephaestus::scales::chrome::{AxisSide, LegendSide};
41use hephaestus::scales::value::Value;
42use hephaestus::scene::SceneBuilder;
43use hephaestus::Renderer;
44
45fn comp_shape(aspect: Option<(f64, f64)>) -> Composition {
46    let facets: Vec<Element> = ["q1", "q2", "q3", "q4"]
47        .into_iter()
48        .map(|id| Patch::new(id).into())
49        .collect();
50    let inner_2x2 = grid(2, 2, facets);
51    let outer = beside(inner_2x2, Patch::new("summary"));
52    match aspect {
53        Some((aw, ah)) => outer.aspect(aw, ah),
54        None => outer,
55    }
56}
57
58fn main() {
59    let (w, h) = (1400u32, 700u32);
60    let dpi = 96.0;
61
62    let xs: Vec<f64> = (0..50).map(|i| i as f64 * 2.0).collect();
63    let make = |phase: f64, amp: f64| -> Vec<f64> {
64        xs.iter()
65            .map(|x| 50.0 + amp * (x * 0.05 + phase).sin())
66            .collect()
67    };
68
69    let datasets = [
70        ("q1", make(0.0, 25.0), rgb8(220, 90, 70)),
71        ("q2", make(1.5, 18.0), rgb8(70, 120, 220)),
72        ("q3", make(3.0, 22.0), rgb8(70, 180, 120)),
73        ("q4", make(4.5, 28.0), rgb8(180, 130, 80)),
74        ("summary", make(0.0, 35.0), rgb8(130, 80, 180)),
75    ];
76
77    let mut renderer = VelloRenderer::new().expect("vello renderer init");
78    let bg: Color = rgb8(248, 248, 252);
79
80    // ── Renders 1 & 2: shared "time" scale across the unlocked layout
81    {
82        #[allow(unused_mut)]
83        let mut view = PlotComposition::new(&comp_shape(None))
84            .add_scale("time", scale::continuous(0.0..=100.0))
85            .add_scale("y", scale::continuous(0.0..=100.0))
86            .title("Sensor array")
87            .subtitle("Four quadrants and a summary, sharing one time scale")
88            .caption("Composition-level chrome spans every panel");
89        attach_all(&mut view, &xs, &datasets);
90
91        // One axis title and one legend for the whole grid, set on the
92        // composition rather than on any single plot. The legend reads
93        // its rows from the "series" scale's domain; no plot needs to
94        // bind that scale for the legend to resolve it.
95        let mut view = {
96            let series: Vec<Value> = datasets.iter().map(|(id, _, _)| Value::from(*id)).collect();
97            let colors: Vec<Color> = datasets.iter().map(|(_, _, c)| *c).collect();
98            let mut view = view
99                .add_scale("series", scale::discrete(series).range_colors(colors))
100                .axis_title(AxisSide::Bottom, "Time (s)");
101            view.add_legend(
102                Legend::new("series")
103                    .side(LegendSide::Right)
104                    .title("Series")
105                    .key(
106                        LegendKeySpec::point()
107                            .scaled("fill", "series")
108                            .fixed("size", 6.0_f64),
109                    ),
110            );
111            view
112        };
113
114        let issues = view.validate();
115        if !issues.is_empty() {
116            panic!("validate() reported issues: {issues:?}");
117        }
118
119        render_to(
120            &mut renderer,
121            &mut view,
122            w,
123            h,
124            dpi,
125            bg,
126            "examples/faceted_1_initial.png",
127        );
128
129        view.update_scale("time", |s| s.set_domain_continuous(20.0, 60.0));
130        render_to(
131            &mut renderer,
132            &mut view,
133            w,
134            h,
135            dpi,
136            bg,
137            "examples/faceted_2_shared_zoom.png",
138        );
139    }
140
141    // ── Render 3: aspect-locked. Outer `.aspect(1, 1)` propagates to
142    //    every leaf panel; selective respect on the layout solver
143    //    couples panel col/row at the locked ratio and lets unmarked
144    //    fr tracks absorb slack. Wider viewport (1800×600) makes the
145    //    lock visually obvious — without it, the 1×2 outer would give
146    //    half the width to each side; with it, the leaf panels land
147    //    at the locked ratio and the surrounding tracks soak up the
148    //    horizontal slack.
149    {
150        let (lw, lh) = (1800u32, 600u32);
151        let mut view = PlotComposition::new(&comp_shape(Some((1.0, 1.0))))
152            .add_scale("time", scale::continuous(20.0..=60.0))
153            .add_scale("y", scale::continuous(0.0..=100.0));
154        attach_all(&mut view, &xs, &datasets);
155        render_to(
156            &mut renderer,
157            &mut view,
158            lw,
159            lh,
160            dpi,
161            bg,
162            "examples/faceted_3_aspect_locked.png",
163        );
164    }
165}
166
167fn attach_all(view: &mut PlotComposition, xs: &[f64], datasets: &[(&str, Vec<f64>, Color)]) {
168    // `Plot::new` reads the composition only to check that the patch id
169    // exists, so one shape serves every plot here. A plot's own aspect
170    // lock would come from `Plot::aspect_ratio`; render 3 instead locks
171    // the outer composition and lets it cascade to the leaves.
172    let shape = comp_shape(None);
173    for (id, ys, color) in datasets {
174        let mut p = Plot::new(&shape, *id).bind("x", "time").bind("y", "y");
175        p.add_geom(
176            PointGeom::builder()
177                .set("x", xs.to_vec())
178                .set("y", ys.clone())
179                .set("fill", *color)
180                .set("size", 4.0_f64)
181                .build(),
182        );
183        {
184            p.add_axis(Axis::rail(
185                "time",
186                AxisPlacement::Cartesian(AxisSide::Bottom),
187            ));
188            p.add_axis(Axis::rail("y", AxisPlacement::Cartesian(AxisSide::Left)));
189        }
190        view.attach_plot(p);
191    }
192}
193
194fn render_to(
195    renderer: &mut VelloRenderer,
196    view: &mut PlotComposition,
197    w: u32,
198    h: u32,
199    dpi: f64,
200    bg: Color,
201    out_relative: &str,
202) {
203    {
204        let scene = renderer.scene();
205        scene.clear();
206        view.render(scene, Size::new(w as f64, h as f64), dpi);
207    }
208    let mut pixels = vec![0u8; (w * h * 4) as usize];
209    renderer
210        .render_to_buffer(w, h, bg, &mut pixels)
211        .expect("render");
212    let path = std::env::current_dir().unwrap().join(out_relative);
213    hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
214    println!("wrote {}", path.display());
215}