Skip to main content

nesting_faceted_title/
nesting_faceted_title.rs

1//! Faceted-plot idiom: a grid of plots with a full-width title,
2//! subtitle, and caption that span the entire width of the facet grid.
3//!
4//! Uses `Composition`'s chrome API — `.id(...)` plus `.slot(Slot::Title,
5//! ...)` etc. directly on the composition. The composition is then
6//! treated as a "simplified plot" wrapping its facets in the canonical
7//! 13×16 anatomical block; chrome slots sit at canonical positions
8//! around the panel band that holds the facets.
9//!
10//! Mirrors patchwork's `simplify_gt.gtable_patchwork` + `plot_annotation`
11//! use case in one move:
12//!
13//! ```text
14//!   grid(rows, cols, [...])
15//!       .id("plot")
16//!       .slot(Slot::Title, ...)
17//!       .slot(Slot::Subtitle, ...)
18//!       .slot(Slot::AxisLeftTitle, ...)
19//!       .slot(Slot::Caption, ...)
20//! ```
21//!
22//! Writes `examples/nesting_faceted_title.png`.
23
24use hephaestus::backend::vello::VelloRenderer;
25use hephaestus::color::{rgb8, Color};
26use hephaestus::composition::{grid, Patch, Slot};
27use hephaestus::layout::Cell;
28use hephaestus::text::{draw_text_in_rect, TextRun, TextStyle};
29use hephaestus::{Affine, Brush, FillRule, Path, PickId, Renderer, SceneBuilder};
30use kurbo::Shape;
31
32fn text_cell(text: &str, size: f32) -> Cell {
33    Cell::measured(TextRun::new(text, &TextStyle::new(size), 96.0))
34}
35
36fn weighted_text_cell(text: &str, size: f32, weight: u16) -> Cell {
37    Cell::measured(TextRun::new(
38        text,
39        &TextStyle::new(size).weight(weight),
40        96.0,
41    ))
42}
43
44fn facet(id: &str, axis_left: &str, axis_bottom: &str) -> Patch {
45    Patch::new(id)
46        .slot(Slot::AxisLeft, text_cell(axis_left, 11.0))
47        .slot(Slot::AxisBottom, text_cell(axis_bottom, 11.0))
48        .slot(Slot::Panel, Cell::empty())
49}
50
51fn color_for_region(region: &str) -> Color {
52    match region {
53        "panel" => rgb8(40, 60, 90),
54        "title" => rgb8(220, 180, 90),
55        "subtitle" => rgb8(180, 150, 70),
56        "caption" => rgb8(180, 130, 90),
57        "axis_left" | "axis_bottom" => rgb8(160, 100, 130),
58        _ => rgb8(120, 120, 120),
59    }
60}
61
62fn main() {
63    let (w, h) = (1400u32, 800u32);
64    let dpi = 96.0;
65
66    // A 2×3 grid of facets, each with its own axis chrome. The
67    // composition is then annotated with shared chrome via `.slot(...)`
68    // directly — no wrapper composition, no manual span calculations.
69    let mut facet_cells = Vec::new();
70    for r in 1..=2 {
71        for c in 1..=3 {
72            let id = format!("f{}_{}", r, c);
73            facet_cells.push(facet(&id, "0\n50\n100", "0  25  50").into());
74        }
75    }
76    let composed = grid(2, 3, facet_cells)
77        .id("plot")
78        .slot(
79            Slot::Title,
80            weighted_text_cell("Reactor diagnostics across runs", 26.0, 700),
81        )
82        .slot(
83            Slot::Subtitle,
84            text_cell(
85                "Each panel: a separate 60-minute run; same scales throughout",
86                15.0,
87            ),
88        )
89        .slot(
90            Slot::AxisLeftTitle,
91            weighted_text_cell("Pressure (kPa)", 14.0, 500),
92        )
93        .slot(
94            Slot::AxisBottomTitle,
95            weighted_text_cell("Elapsed time (minutes)", 14.0, 500),
96        )
97        .slot(
98            Slot::Caption,
99            text_cell(
100                "Source: in-line telemetry, smoothed at 1s; runs ordered chronologically.",
101                11.0,
102            ),
103        );
104
105    let layout = composed.solve(hephaestus::Size::new(w as f64, h as f64), dpi);
106
107    let mut renderer = VelloRenderer::new().expect("vello renderer init");
108    {
109        let scene = renderer.scene();
110        let stroke = hephaestus::stroke::Stroke::new(1.0);
111        let text_brush: Brush = rgb8(20, 20, 30).into();
112
113        for (_id, region, rect) in layout.iter() {
114            if region == "panel" {
115                continue;
116            }
117            let c = color_for_region(region);
118            let tint = Color::new([c.components[0], c.components[1], c.components[2], 0.20]);
119            let path: Path = rect.to_path(0.1);
120            scene.fill(
121                FillRule::NonZero,
122                Affine::IDENTITY,
123                &Brush::Solid(tint),
124                None,
125                &path,
126                PickId::Skip,
127            );
128            scene.stroke(
129                &stroke,
130                Affine::IDENTITY,
131                &Brush::Solid(c),
132                None,
133                &path,
134                PickId::Skip,
135            );
136        }
137        for (_id, region, rect) in layout.iter() {
138            if region != "panel" {
139                continue;
140            }
141            let path: Path = rect.to_path(0.1);
142            scene.fill(
143                FillRule::NonZero,
144                Affine::IDENTITY,
145                &Brush::Solid(color_for_region(region)),
146                None,
147                &path,
148                PickId::Skip,
149            );
150            scene.stroke(
151                &stroke,
152                Affine::IDENTITY,
153                &Brush::Solid(rgb8(255, 255, 255)),
154                None,
155                &path,
156                PickId::Skip,
157            );
158        }
159
160        for (id, region, rect) in layout.iter() {
161            let (text, size, weight) = match (id, region) {
162                ("plot", "title") => ("Reactor diagnostics across runs", 26.0, 700),
163                ("plot", "subtitle") => (
164                    "Each panel: a separate 60-minute run; same scales throughout",
165                    15.0,
166                    400,
167                ),
168                ("plot", "caption") => (
169                    "Source: in-line telemetry, smoothed at 1s; runs ordered chronologically.",
170                    11.0,
171                    400,
172                ),
173                ("plot", "axis_left_title") => ("Pressure (kPa)", 14.0, 500),
174                ("plot", "axis_bottom_title") => ("Elapsed time (minutes)", 14.0, 500),
175                (_, "axis_left") => ("0\n50\n100", 11.0, 400),
176                (_, "axis_bottom") => ("0  25  50", 11.0, 400),
177                _ => continue,
178            };
179            let run = TextRun::new(text, &TextStyle::new(size).weight(weight), 96.0);
180            draw_text_in_rect(scene, &run, rect, &text_brush, PickId::Skip);
181        }
182    }
183
184    let mut pixels = vec![0u8; (w * h * 4) as usize];
185    let bg: Color = rgb8(248, 248, 252);
186    renderer
187        .render_to_buffer(w, h, bg, &mut pixels)
188        .expect("render");
189
190    let path = std::env::current_dir()
191        .unwrap()
192        .join("examples/nesting_faceted_title.png");
193    hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
194    println!("wrote {}", path.display());
195
196    // Sanity check: chrome rects line up with the facet panel band.
197    let title = layout.get("plot", Slot::Title).unwrap();
198    let caption = layout.get("plot", Slot::Caption).unwrap();
199    let axis_left_title = layout.get("plot", Slot::AxisLeftTitle).unwrap();
200    let axis_bottom_title = layout.get("plot", Slot::AxisBottomTitle).unwrap();
201    let f1_1_panel = layout.get("f1_1", Slot::Panel).unwrap();
202    let f1_3_panel = layout.get("f1_3", Slot::Panel).unwrap();
203    let f2_3_panel = layout.get("f2_3", Slot::Panel).unwrap();
204    println!(
205        "plot.title:             x0={:>5.0}  x1={:>5.0}  y0={:>5.0}  y1={:>5.0}",
206        title.x0, title.x1, title.y0, title.y1
207    );
208    println!(
209        "plot.caption:           x0={:>5.0}  x1={:>5.0}  y0={:>5.0}  y1={:>5.0}",
210        caption.x0, caption.x1, caption.y0, caption.y1
211    );
212    println!(
213        "plot.axis_left_title:   x0={:>5.0}  x1={:>5.0}  y0={:>5.0}  y1={:>5.0}",
214        axis_left_title.x0, axis_left_title.x1, axis_left_title.y0, axis_left_title.y1
215    );
216    println!(
217        "plot.axis_bottom_title: x0={:>5.0}  x1={:>5.0}  y0={:>5.0}  y1={:>5.0}",
218        axis_bottom_title.x0, axis_bottom_title.x1, axis_bottom_title.y0, axis_bottom_title.y1
219    );
220    println!(
221        "facets panel band:      x0={:>5.0}  x1={:>5.0}  y0={:>5.0}  y1={:>5.0}",
222        f1_1_panel.x0, f1_3_panel.x1, f1_1_panel.y0, f2_3_panel.y1
223    );
224}