Skip to main content

legends/
legends.rs

1//! Legend rendering — manual `Legend` API.
2//!
3//! Three legends, one per side:
4//!
5//! 1. **Right** (top stack): two attached legends collapsed into one
6//!    at render time — a `Line` key whose stroke is scaled by the
7//!    category colour, and a `Point` key whose fill is scaled by the
8//!    same colour scale but whose stroke is **fixed** black. The
9//!    Point's stroke does NOT pick up the line's stroke scale because
10//!    each key carries its own per-aesthetic bindings.
11//!
12//! 2. **Top**: a `Point` key whose size is scaled by `category_size`.
13//!
14//! 3. **Bottom**: a `Line` key whose linetype is scaled by
15//!    `category_line`.
16//!
17//! 4. **Left**: a `Text` key whose font size is scaled by
18//!    `category_size` and whose ink is scaled by `category_color` —
19//!    what a text layer's scales read as.
20//!
21//! Produces `examples/legends.png`.
22
23use std::sync::Arc;
24
25use hephaestus::backend::vello::VelloRenderer;
26use hephaestus::color::{rgb, rgb8, Color};
27use hephaestus::composition::{Composition, Patch, Span};
28use hephaestus::geometry::Size;
29use hephaestus::plot::chrome::axis::{Axis, AxisPlacement};
30use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec};
31use hephaestus::plot::geom::linetype::{dashed, dotted, solid};
32use hephaestus::plot::{scale, Plot, PlotComposition, PointGeom};
33use hephaestus::scales::chrome::{Anchor, AxisSide, LegendSide};
34use hephaestus::scales::value::Value;
35use hephaestus::scene::SceneBuilder;
36use hephaestus::shape::ShapeRegistry;
37use hephaestus::text::{glyph_marker, TextStyle};
38use hephaestus::Renderer;
39
40fn comp() -> Composition {
41    Composition::empty(1, 1).place(1, 1, Span::cell(), Patch::new("panel"))
42}
43
44fn main() {
45    let (w, h) = (900u32, 600u32);
46    let dpi = 96.0;
47
48    // ── Data ──
49    let n = 24;
50    let xs: Vec<f64> = (0..n).map(|i| i as f64 * 0.4).collect();
51    let ys: Vec<f64> = (0..n)
52        .map(|i| ((i as f64) * 0.5).sin() * 0.4 + 0.5)
53        .collect();
54    let cats: [&'static str; 4] = ["A", "B", "C", "D"];
55    let fill_col: Vec<&'static str> = (0..n).map(|i| cats[i % 4]).collect();
56    let size_col: Vec<&'static str> = (0..n).map(|i| cats[i % 4]).collect();
57
58    // Build the plot's shape registry with the built-in vector
59    // shapes plus a handful of emoji glyphs used by the binned
60    // legend below. Glyph shapes share the same registry surface
61    // as the vector ones (insert by name; look up by name) so the
62    // legend chrome can resolve either.
63    let glyph_style = TextStyle::new(16.0);
64    let mut shapes = ShapeRegistry::with_builtins();
65    // U+1F4A7 droplet, U+1F31E sun, U+1F525 fire, U+1F33F herb,
66    // U+1F30A wave — picked to read as a "low → high" intensity
67    // ramp matching the gradient_scale values.
68    shapes.insert("droplet", glyph_marker("\u{1F4A7}", &glyph_style));
69    shapes.insert("herb", glyph_marker("\u{1F33F}", &glyph_style));
70    shapes.insert("sun", glyph_marker("\u{1F31E}", &glyph_style));
71    shapes.insert("wave", glyph_marker("\u{1F30A}", &glyph_style));
72    shapes.insert("fire", glyph_marker("\u{1F525}", &glyph_style));
73
74    let mut p = Plot::new(&comp(), "panel")
75        .bind("x", "x")
76        .bind("y", "y")
77        .bind("fill", "category_color")
78        .bind("size", "category_size")
79        .shape_registry(shapes);
80    p.add_geom(
81        PointGeom::builder()
82            .set("x", xs)
83            .set("y", ys)
84            .set("fill", fill_col)
85            .set("size", size_col)
86            .build(),
87    );
88    p.add_axis(Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom)));
89    p.add_axis(Axis::rail("y", AxisPlacement::Cartesian(AxisSide::Left)));
90
91    // ── Right side, legend #1: line + point, both driven by
92    // category_color. Attached as two legends; they share a side,
93    // title and domain scale, so the render-time collapse folds the
94    // second one's key into the first.
95    p.add_legend(
96        Legend::new("category_color")
97            .side(LegendSide::Right)
98            .title("Category")
99            .key(LegendKeySpec::line().scaled("stroke", "category_color")),
100    );
101    p.add_legend(
102        Legend::new("category_color")
103            .side(LegendSide::Right)
104            .title("Category")
105            .key(
106                LegendKeySpec::point()
107                    .scaled("fill", "category_color")
108                    .fixed("stroke", Value::Color(rgb(0.0, 0.0, 0.0)))
109                    .fixed("size", 6.0_f64),
110            ),
111    );
112
113    // ── Right side, legend #2 (stacks below #1): a size legend that
114    // shares the Right slot. `category_size` is trained to the same
115    // categories as `category_color`, so what keeps this a block of
116    // its own is the differing title; the per-side stacker then
117    // arranges both legends vertically.
118    p.add_legend(
119        Legend::new("category_size")
120            .side(LegendSide::Right)
121            .title("Size")
122            .key(
123                LegendKeySpec::point()
124                    .scaled("size", "category_size")
125                    .fixed("fill", Value::Color(rgb(0.25, 0.25, 0.25))),
126            ),
127    );
128
129    // ── Right side, legend #3: a continuous gradient colorbar
130    // driven by `gradient_scale`. Stacks below the discrete ones
131    // via the same per-side stacker. The colorbar also pulls its
132    // opacity from `gradient_opacity` — same domain → low values are
133    // mostly transparent, high values fully opaque.
134    p.add_legend(
135        Legend::colorbar("gradient_scale")
136            .side(LegendSide::Right)
137            .title("Gradient")
138            .scaled("fill_opacity", "gradient_opacity"),
139    );
140
141    // ── Bottom side, legend #3: same gradient scale but rendered
142    // as discrete steps at each break — useful for showing a
143    // binned colour mapping or any colorbar you want to read as
144    // categorical bands. Routed to the Bottom slot so it stacks
145    // horizontally alongside the existing "Pattern" + "Colour"
146    // legends.
147    p.add_legend(
148        Legend::colorbar("gradient_scale")
149            .side(LegendSide::Bottom)
150            .title("Steps")
151            .binned()
152            .open_upper(),
153    );
154
155    // ── Bottom side, legend #4: a **binned stack** that varies
156    // SHAPE per bin (instead of colour like the stepped colorbar
157    // next to it). Six bin boundaries from `gradient_scale` → five
158    // bins, each rendered as a different emoji-glyph shape via the
159    // `bin_shape` scale (resolved against the plot's shape
160    // registry). Demonstrates that the same legend key wiring
161    // works with vector and glyph-backed shapes.
162    //
163    // Emoji glyphs fill their em-bbox almost completely (Latin
164    // letters and vector circles only fill ~70–80 % of the same
165    // bbox), so a 12pt emoji reads at roughly the same visual
166    // weight as the 16pt circle markers used by the other legends.
167    p.add_legend(
168        Legend::new("gradient_scale")
169            .side(LegendSide::Bottom)
170            .title("Bins")
171            .binned()
172            .equal_bins()
173            .key(
174                LegendKeySpec::point()
175                    .scaled("shape", "bin_shape")
176                    .fixed("fill", Value::Color(rgb(0.15, 0.15, 0.15)))
177                    .fixed("size", 12.0_f64),
178            ),
179    );
180
181    // ── Left side: a text key. The swatch is a glyph sample rather
182    // than a marker, so a font-size scale shows what it actually
183    // does to type. `size` is the font size in pt and `fill` is the
184    // ink; both resolve per row through their own scale.
185    p.add_legend(
186        Legend::new("category_size")
187            .side(LegendSide::Left)
188            .title("Font size")
189            .key(
190                LegendKeySpec::text()
191                    .scaled("size", "category_size")
192                    .scaled("fill", "category_color")
193                    .fixed("text", Value::String(Arc::from("Aa"))),
194            ),
195    );
196
197    // ── Bottom side: two legends side-by-side (horizontal stack).
198    p.add_legend(
199        Legend::new("category_line")
200            .side(LegendSide::Bottom)
201            .title("Pattern")
202            .key(
203                LegendKeySpec::line()
204                    .scaled("linetype", "category_line")
205                    .fixed("linewidth", 1.5_f64),
206            ),
207    );
208    p.add_legend(
209        Legend::new("category_color")
210            .side(LegendSide::Bottom)
211            .title("Colour")
212            .key(LegendKeySpec::rect().scaled("fill", "category_color")),
213    );
214
215    // ── In-panel overlay: a compact category legend pinned to the
216    // top-right corner of the panel area. Reserves no chrome space —
217    // the data marks beneath continue to occupy the full panel rect.
218    p.add_legend(
219        Legend::new("category_color")
220            .side(LegendSide::InPanel {
221                anchor: Anchor::TopRight,
222                inset_pt: 8.0,
223            })
224            .title("Overlay")
225            .key(
226                LegendKeySpec::point()
227                    .scaled("fill", "category_color")
228                    .fixed("size", 6.0_f64),
229            ),
230    );
231
232    let cat_values: Vec<Value> = cats.iter().map(|s| Value::String(Arc::from(*s))).collect();
233    let line_cats: [&'static str; 3] = ["Solid", "Dashed", "Dotted"];
234    let line_values: Vec<Value> = line_cats
235        .iter()
236        .map(|s| Value::String(Arc::from(*s)))
237        .collect();
238
239    let mut view = PlotComposition::new(&comp())
240        .add_scale("x", scale::continuous(0.0..=10.0))
241        .add_scale("y", scale::continuous(0.0..=1.0))
242        .add_scale(
243            "category_color",
244            scale::discrete(cat_values.clone()).range_colors([
245                rgb8(220, 90, 70),
246                rgb8(70, 160, 90),
247                rgb8(70, 120, 220),
248                rgb8(180, 120, 200),
249            ]),
250        )
251        .add_scale(
252            "category_size",
253            scale::discrete(cat_values).range_numbers([4.0, 8.0, 12.0, 16.0]),
254        )
255        .add_scale(
256            "category_line",
257            scale::discrete(line_values).range_linetypes([solid(), dashed(), dotted()]),
258        )
259        .add_scale(
260            "gradient_scale",
261            scale::continuous(0.0..=100.0).range_colors([
262                rgb8(20, 30, 90),
263                rgb8(60, 160, 200),
264                rgb8(230, 220, 100),
265                rgb8(220, 60, 40),
266            ]),
267        )
268        .add_scale(
269            "gradient_opacity",
270            scale::continuous(0.0..=100.0).range_numbers([0.1, 1.0]),
271        )
272        .add_scale(
273            "bin_shape",
274            scale::continuous(0.0..=100.0).range_strings([
275                Arc::from("droplet"),
276                Arc::from("herb"),
277                Arc::from("sun"),
278                Arc::from("wave"),
279                Arc::from("fire"),
280            ]),
281        );
282    view.attach_plot(p);
283
284    let issues = view.validate();
285    if !issues.is_empty() {
286        panic!("validate(): {issues:?}");
287    }
288
289    let mut renderer = VelloRenderer::new().expect("vello renderer init");
290    let bg: Color = rgb8(252, 252, 252);
291    {
292        let scene = renderer.scene();
293        scene.clear();
294        view.render(scene, Size::new(w as f64, h as f64), dpi);
295    }
296    let mut pixels = vec![0u8; (w * h * 4) as usize];
297    renderer
298        .render_to_buffer(w, h, bg, &mut pixels)
299        .expect("render");
300    let path = std::env::current_dir()
301        .unwrap()
302        .join("examples/legends.png");
303    hephaestus::image::write_png(&path, w, h, &pixels).expect("write png");
304    println!("wrote {}", path.display());
305}