Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/plot.rs

16.8 KiB, 62 runs

created by r1870400018:36579, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Function plots and bar charts as a [`Graphic`](crate::ir::Graphic): axes, ticks, numbered labels and
2//! one or more series, drawn in the same `fe2o3_graphics` paths the body text and diagrams use, so a
3//! plot is first-class content -- stroked geometry and shaped labels, not a pasted raster. A caller
4//! samples a function into a [`Series`] and places the result with [`Block::figure`](crate::doc).
5//!
6//! The frame is fixed points: a left and bottom margin hold the tick labels, the rest is the plot area.
7//! Data coordinates map into that area with y flipped, since the page runs y downwards. Two axis styles
8//! serve the books' figures: a framed plot with a light grid, and a bare left-and-bottom axis pair with
9//! arrow tips and no grid, the way cetz-plot's `axis-style: "left"` draws. A [`BarChart`] draws
10//! horizontal category bars against a bottom value axis, the way cetz-plot's `chart.barchart` does.
11
12use crate::font::ShapedText;
13use crate::ir::{
14 Dims,
15 DrawOp,
16 Graphic,
17 Sp,
18};
19
20use oxedyne_fe2o3_core::prelude::*;
21use oxedyne_fe2o3_font::{
22 face::Role,
23 set::FontSet,
24 shape::Dir,
25};
26use oxedyne_fe2o3_graphics::{
27 colour::Rgba,
28 path::{
29 PathBuilder,
30 Pt,
31 },
32 transform::Transform,
33};
34
35use std::sync::Arc;
36
37/// How a plot's axes are drawn. `Framed` boxes the area with a light grid at every tick; `Left` draws
38/// only the left and bottom axes as arrow-tipped lines with short tick marks and no grid, matching
39/// cetz-plot's `axis-style: "left"`.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum AxisStyle {
42 Framed,
43 Left,
44}
45
46/// One curve: its points in data coordinates, its pen colour and its stroke width in points. A dashed
47/// curve is stroked as a run of short segments; a labelled curve appears in the legend.
48#[derive(Clone, Debug)]
49pub struct Series {
50 pub points: Vec<(f64, f64)>,
51 pub colour: Rgba,
52 pub width: f32,
53 pub dashed: bool,
54 pub label: Option<String>,
55}
56
57/// A plot to be built into a figure. The ranges fix the data window; the ticks are where a mark and a
58/// numeric label are drawn on each axis (and, in the framed style, a grid line).
59#[derive(Clone, Debug)]
60pub struct Plot {
61 pub width: f32, // overall figure width, points
62 pub height: f32, // overall figure height, points
63 pub x_range: (f64, f64),
64 pub y_range: (f64, f64),
65 pub x_ticks: Vec<f64>,
66 pub y_ticks: Vec<f64>,
67 pub series: Vec<Series>,
68 pub axis: AxisStyle,
69 pub x_label: Option<String>, // centred beneath the x axis
70 pub y_label: Option<String>, // set at the top of the y axis
71 pub legend: bool, // draw a legend from the series labels, inset top-left
72}
73
74impl Plot {
75 /// Builds the plot into a [`Graphic`] sized to `width` x `height`, its ink drawn from the top-left of
76 /// that box in page coordinates so the figure placement can treat it like any other drawn block.
77 pub fn build(&self, fonts: Arc<FontSet>) -> Outcome<Graphic> {
78 let left_axis = self.axis == AxisStyle::Left;
79 let ml = 34.0_f32; // left margin, for the y tick labels
80 let mb = 22.0_f32; // bottom margin, for the x tick labels
81 let mt = 12.0_f32; // top margin, room for an axis arrow tip
82 let mr = 14.0_f32; // right margin, likewise
83 let pw = self.width - ml - mr;
84 let ph = self.height - mt - mb;
85
86 let (x0, x1) = self.x_range;
87 let (y0, y1) = self.y_range;
88 let sx = |x: f64| -> f32 { ml + ((x - x0) / (x1 - x0)) as f32 * pw };
89 let sy = |y: f64| -> f32 { mt + (1.0 - ((y - y0) / (y1 - y0)) as f32) * ph };
90
91 let grid = Rgba::opaque(225, 225, 225);
92 let axis = Rgba::opaque(120, 120, 120);
93 let frame = Rgba::opaque(90, 90, 90);
94 let ink = Rgba::opaque(30, 30, 30);
95
96 let mut ops: Vec<DrawOp> = Vec::new();
97
98 if left_axis {
99 // The two axes as arrow-tipped lines: the y axis up the left, the x axis along the bottom, each
100 // running a little past the plot area to carry its arrowhead, and no grid behind them.
101 let ax = ml; // the y axis sits at the left edge of the area
102 let ay = mt + ph; // the x axis sits at the bottom of the area
103 let tip = 6.0_f32;
104 ops.push(res!(seg(ax, ay, ax, mt - tip, ink, 1.0))); // y axis, up
105 ops.push(res!(seg(ax, ay, ml + pw + tip, ay, ink, 1.0))); // x axis, right
106 ops.push(res!(arrow_tip(ax, mt - tip, 0.0, -1.0, ink)));
107 ops.push(res!(arrow_tip(ml + pw + tip, ay, 1.0, 0.0, ink)));
108 // A short inward tick at each labelled value.
109 for &xt in &self.x_ticks {
110 let px = sx(xt);
111 ops.push(res!(seg(px, ay, px, ay - 3.0, ink, 0.8)));
112 }
113 for &yt in &self.y_ticks {
114 let py = sy(yt);
115 ops.push(res!(seg(ax, py, ax + 3.0, py, ink, 0.8)));
116 }
117 } else {
118 // The framed style: a faint grid at each tick, darker zero axes when zero is in range, and a
119 // full frame around the area.
120 for &xt in &self.x_ticks {
121 let px = sx(xt);
122 ops.push(res!(seg(px, mt, px, mt + ph, grid, 0.4)));
123 }
124 for &yt in &self.y_ticks {
125 let py = sy(yt);
126 ops.push(res!(seg(ml, py, ml + pw, py, grid, 0.4)));
127 }
128 if y0 < 0.0 && y1 > 0.0 {
129 let py = sy(0.0);
130 ops.push(res!(seg(ml, py, ml + pw, py, axis, 0.6)));
131 }
132 if x0 < 0.0 && x1 > 0.0 {
133 let px = sx(0.0);
134 ops.push(res!(seg(px, mt, px, mt + ph, axis, 0.6)));
135 }
136 }
137
138 // The curves, each a stroked polyline through its mapped samples, dashed or solid.
139 for s in &self.series {
140 let pts: Vec<Pt> = s.points.iter().map(|(x, y)| Pt::new(sx(*x), sy(*y))).collect();
141 if s.dashed {
142 res!(dash_polyline(&mut ops, &pts, s.colour, s.width));
143 } else {
144 let mut pb = PathBuilder::new();
145 for (k, p) in pts.iter().enumerate() {
146 if k == 0 { pb.move_to(*p); } else { pb.line_to(*p); }
147 }
148 ops.push(DrawOp::Stroke { path: res!(pb.finish()), colour: s.colour, width: s.width });
149 }
150 }
151
152 if !left_axis {
153 ops.push(res!(seg(ml, mt, ml + pw, mt, frame, 0.6)));
154 ops.push(res!(seg(ml, mt + ph, ml + pw, mt + ph, frame, 0.6)));
155 ops.push(res!(seg(ml, mt, ml, mt + ph, frame, 0.6)));
156 ops.push(res!(seg(ml + pw, mt, ml + pw, mt + ph, frame, 0.6)));
157 }
158
159 // The tick labels, shaped small and baked to glyph outlines like any other run.
160 let size = Sp::from_pt(8.5);
161 for &xt in &self.x_ticks {
162 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, &fmt_tick(xt)));
163 let w = shaped.dims().width.to_pt() as f32;
164 let asc = shaped.dims().height.to_pt() as f32;
165 res!(bake(&mut ops, &shaped, sx(xt) - w / 2.0, mt + ph + 5.0 + asc));
166 }
167 for &yt in &self.y_ticks {
168 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, &fmt_tick(yt)));
169 let w = shaped.dims().width.to_pt() as f32;
170 let asc = shaped.dims().height.to_pt() as f32;
171 res!(bake(&mut ops, &shaped, ml - 5.0 - w, sy(yt) + asc / 2.0));
172 }
173
174 // The axis labels.
175 if let Some(xl) = &self.x_label {
176 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, xl));
177 let w = shaped.dims().width.to_pt() as f32;
178 res!(bake(&mut ops, &shaped, ml + pw / 2.0 - w / 2.0, self.height - 2.0));
179 }
180 if let Some(yl) = &self.y_label {
181 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, yl));
182 let w = shaped.dims().width.to_pt() as f32;
183 res!(bake(&mut ops, &shaped, ml - w / 2.0, mt - 3.0));
184 }
185
186 // The legend, inset a little from the top-left of the plot area: a short line sample of each
187 // labelled series, its own dash, and its label to the right.
188 if self.legend {
189 let lsize = Sp::from_pt(9.5);
190 let lx = ml + 14.0;
191 let mut ly = mt + 12.0;
192 let sample = 22.0_f32;
193 for s in &self.series {
194 let label = match &s.label {
195 Some(l) => l,
196 None => continue,
197 };
198 let a = Pt::new(lx, ly);
199 let b = Pt::new(lx + sample, ly);
200 if s.dashed {
201 res!(dash_polyline(&mut ops, &[a, b], s.colour, s.width));
202 } else {
203 let mut pb = PathBuilder::new();
204 pb.move_to(a);
205 pb.line_to(b);
206 ops.push(DrawOp::Stroke { path: res!(pb.finish()), colour: s.colour, width: s.width });
207 }
208 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, lsize, label));
209 let asc = shaped.dims().height.to_pt() as f32;
210 res!(bake(&mut ops, &shaped, lx + sample + 6.0, ly + asc / 2.0));
211 ly += 16.0;
212 }
213 }
214
215 Ok(Graphic {
216 ops,
217 dims: Dims::new(Sp::from_pt(self.width as f64), Sp::from_pt(self.height as f64), Sp::ZERO),
218 link: None,
219 })
220 }
221}
222
223/// A horizontal bar chart: one bar per category, drawn top to bottom, against a bottom value axis with a
224/// light dashed grid, matching cetz-plot's `chart.barchart(mode: "basic")`. The category label sits to
225/// the left of each bar; the value axis carries numbered ticks and an optional label beneath.
226#[derive(Clone, Debug)]
227pub struct BarChart {
228 pub width: f32, // value-axis (plot area) width, points
229 pub height: f32, // category stack height, points
230 pub bars: Vec<(String, f64)>, // category label and value, in draw order top to bottom
231 pub x_max: f64, // the value axis maximum
232 pub x_ticks: Vec<f64>,
233 pub x_label: Option<String>, // centred beneath the value axis
234 pub bar_frac: f64, // bar thickness as a fraction of its row slot
235 pub fills: Vec<Rgba>, // the fill cycled across bars
236}
237
238impl BarChart {
239 /// Builds the bar chart into a [`Graphic`]. The left margin is sized to the widest category label so
240 /// no label is clipped; the bottom margin holds the value ticks and the axis label.
241 pub fn build(&self, fonts: Arc<FontSet>) -> Outcome<Graphic> {
242 let lsize = Sp::from_pt(9.5);
243 let tsize = Sp::from_pt(8.5);
244
245 // The left margin follows the widest category label.
246 let mut ml = 8.0_f32;
247 for (label, _) in &self.bars {
248 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, lsize, label));
249 let w = shaped.dims().width.to_pt() as f32;
250 if w + 10.0 > ml {
251 ml = w + 10.0;
252 }
253 }
254 let mb = if self.x_label.is_some() { 30.0 } else { 20.0 };
255 let mt = 4.0_f32;
256 let mr = 6.0_f32;
257 let pw = self.width;
258 let ph = self.height;
259 let total_w = ml + pw + mr;
260 let total_h = mt + ph + mb;
261
262 let x_max = if self.x_max > 0.0 { self.x_max } else { 1.0 };
263 let sx = |v: f64| -> f32 { ml + (v / x_max) as f32 * pw };
264
265 let grid = Rgba::opaque(190, 190, 190);
266 let ink = Rgba::opaque(30, 30, 30);
267
268 let mut ops: Vec<DrawOp> = Vec::new();
269
270 // The dashed vertical grid and the value ticks along the bottom.
271 let axis_y = mt + ph;
272 for &xt in &self.x_ticks {
273 let px = sx(xt);
274 res!(dash_polyline(&mut ops, &[Pt::new(px, mt), Pt::new(px, axis_y)], grid, 0.5));
275 }
276
277 // Each bar in its row slot, filled from the cycle and outlined.
278 let n = self.bars.len().max(1);
279 let slot = ph / n as f32;
280 let bar_h = (slot * self.bar_frac as f32).max(1.0);
281 for (i, (label, value)) in self.bars.iter().enumerate() {
282 let cy = mt + slot * (i as f32 + 0.5);
283 let top = cy - bar_h / 2.0;
284 let right = sx(*value);
285 let fill = self.fills.get(i % self.fills.len().max(1)).copied().unwrap_or(Rgba::opaque(220, 90, 90));
286 ops.push(res!(filled_rect(ml, top, right, top + bar_h, fill)));
287 ops.push(res!(rect_outline(ml, top, right, top + bar_h, ink, 0.8)));
288 // The category label, right-aligned into the left margin, vertically centred on the bar.
289 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, lsize, label));
290 let w = shaped.dims().width.to_pt() as f32;
291 let asc = shaped.dims().height.to_pt() as f32;
292 let dep = shaped.dims().depth.to_pt() as f32;
293 res!(bake(&mut ops, &shaped, ml - 6.0 - w, cy + (asc - dep) / 2.0));
294 }
295
296 // The value tick labels beneath the axis.
297 for &xt in &self.x_ticks {
298 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, tsize, &fmt_tick(xt)));
299 let w = shaped.dims().width.to_pt() as f32;
300 let asc = shaped.dims().height.to_pt() as f32;
301 res!(bake(&mut ops, &shaped, sx(xt) - w / 2.0, axis_y + 4.0 + asc));
302 }
303 if let Some(xl) = &self.x_label {
304 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, tsize, xl));
305 let w = shaped.dims().width.to_pt() as f32;
306 res!(bake(&mut ops, &shaped, ml + pw / 2.0 - w / 2.0, total_h - 2.0));
307 }
308
309 Ok(Graphic {
310 ops,
311 dims: Dims::new(Sp::from_pt(total_w as f64), Sp::from_pt(total_h as f64), Sp::ZERO),
312 link: None,
313 })
314 }
315}
316
317/// A nice value-axis maximum and tick step for a data maximum: the maximum is taken as the data max, and
318/// the step is the "nice" value giving the interval count nearest ten, so a chart of values to 60 ticks
319/// every 6 as cetz-plot does. Returns `(max, ticks)`.
320pub fn nice_bar_axis(data_max: f64) -> (f64, Vec<f64>) {
321 if data_max <= 0.0 {
322 return (1.0, vec![0.0, 1.0]);
323 }
324 let mag = 10f64.powf(data_max.log10().floor());
325 // Candidate step multipliers within a decade; the one whose interval count is nearest ten wins.
326 let cands = [1.0, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0];
327 let mut best_step = mag;
328 let mut best_err = f64::INFINITY;
329 for scale in [mag / 10.0, mag, mag * 10.0] {
330 for c in cands {
331 let step = c * scale;
332 if step <= 0.0 { continue; }
333 let count = data_max / step;
334 if count < 4.0 || count > 14.0 { continue; }
335 let err = (count - 10.0).abs();
336 if err < best_err {
337 best_err = err;
338 best_step = step;
339 }
340 }
341 }
342 let mut ticks = Vec::new();
343 let mut v = 0.0;
344 while v <= data_max + best_step * 0.001 {
345 ticks.push((v * 1e6).round() / 1e6);
346 v += best_step;
347 }
348 (data_max, ticks)
349}
350
351/// A straight stroked segment between two points in figure coordinates.
352fn seg(x0: f32, y0: f32, x1: f32, y1: f32, colour: Rgba, width: f32) -> Outcome<DrawOp> {
353 let mut pb = PathBuilder::new();
354 pb.move_to(Pt::new(x0, y0));
355 pb.line_to(Pt::new(x1, y1));
356 Ok(DrawOp::Stroke { path: res!(pb.finish()), colour, width })
357}
358
359/// A filled axis-aligned rectangle.
360fn filled_rect(x0: f32, y0: f32, x1: f32, y1: f32, colour: Rgba) -> Outcome<DrawOp> {
361 let mut pb = PathBuilder::new();
362 pb.move_to(Pt::new(x0, y0));
363 pb.line_to(Pt::new(x1, y0));
364 pb.line_to(Pt::new(x1, y1));
365 pb.line_to(Pt::new(x0, y1));
366 pb.close();
367 Ok(DrawOp::Fill { path: res!(pb.finish()), colour })
368}
369
370/// A stroked axis-aligned rectangle outline.
371fn rect_outline(x0: f32, y0: f32, x1: f32, y1: f32, colour: Rgba, width: f32) -> Outcome<DrawOp> {
372 let mut pb = PathBuilder::new();
373 pb.move_to(Pt::new(x0, y0));
374 pb.line_to(Pt::new(x1, y0));
375 pb.line_to(Pt::new(x1, y1));
376 pb.line_to(Pt::new(x0, y1));
377 pb.close();
378 Ok(DrawOp::Stroke { path: res!(pb.finish()), colour, width })
379}
380
381/// A small filled triangle at `(x, y)` pointing along `(dx, dy)`, the tip an axis arrowhead takes.
382fn arrow_tip(x: f32, y: f32, dx: f32, dy: f32, colour: Rgba) -> Outcome<DrawOp> {
383 let len = 6.0_f32;
384 let half = 2.6_f32;
385 let (px, py) = (-dy, dx); // unit perpendicular
386 let bx = x - dx * len;
387 let by = y - dy * len;
388 let mut pb = PathBuilder::new();
389 pb.move_to(Pt::new(x, y));
390 pb.line_to(Pt::new(bx + px * half, by + py * half));
391 pb.line_to(Pt::new(bx - px * half, by - py * half));
392 pb.close();
393 Ok(DrawOp::Fill { path: res!(pb.finish()), colour })
394}
395
396/// Strokes a polyline as a run of short dashes, so a dashed curve reads the same in SVG and PDF without
397/// leaning on the emitter's dash support: it walks the line at a fixed dash-and-gap cadence.
398fn dash_polyline(ops: &mut Vec<DrawOp>, pts: &[Pt], colour: Rgba, width: f32) -> Outcome<()> {
399 let dash = 4.0_f32;
400 let gap = 3.0_f32;
401 let period = dash + gap;
402 let mut phase = 0.0_f32; // distance into the current period, carried across segments
403 for w in pts.windows(2) {
404 let (a, b) = (w[0], w[1]);
405 let dx = b.x - a.x;
406 let dy = b.y - a.y;
407 let len = (dx * dx + dy * dy).sqrt();
408 if len <= f32::EPSILON {
409 continue;
410 }
411 let (ux, uy) = (dx / len, dy / len);
412 let mut d = 0.0_f32;
413 while d < len {
414 // Where in the period this point falls: ink while under `dash`, blank after.
415 let into = (phase + d) % period;
416 if into < dash {
417 let seg_end = (d + (dash - into)).min(len);
418 let mut pb = PathBuilder::new();
419 pb.move_to(Pt::new(a.x + ux * d, a.y + uy * d));
420 pb.line_to(Pt::new(a.x + ux * seg_end, a.y + uy * seg_end));
421 ops.push(DrawOp::Stroke { path: res!(pb.finish()), colour, width });
422 d = seg_end;
423 } else {
424 d += period - into;
425 }
426 }
427 phase = (phase + len) % period;
428 }
429 Ok(())
430}
431
432/// Bakes a shaped run as filled glyph outlines at a baseline, flipping the font-frame y-up outline onto
433/// the page's y-down frame, exactly as the diagram labels and the SVG writer do.
434fn bake(ops: &mut Vec<DrawOp>, shaped: &ShapedText, base_x: f32, base_y: f32) -> Outcome<()> {
435 for glyph in &shaped.run().glyphs {
436 let path = res!(shaped.outline(glyph));
437 if path.is_empty() {
438 continue;
439 }
440 let t = Transform::scale(1.0, -1.0)
441 .then(&Transform::translate(base_x + glyph.x, base_y - glyph.y));
442 ops.push(DrawOp::Fill { path: res!(path.transform(&t)), colour: Rgba::BLACK });
443 }
444 Ok(())
445}
446
447/// Formats a tick value compactly: an integer without a decimal point, otherwise to two places with the
448/// trailing zeros and any bare point trimmed, so 0.50 shows as 0.5 and 2.0 as 2.
449fn fmt_tick(v: f64) -> String {
450 if (v - v.round()).abs() < 1e-9 {
451 return fmt!("{}", v.round() as i64);
452 }
453 let s = fmt!("{:.2}", v);
454 let s = s.trim_end_matches('0');
455 s.trim_end_matches('.').to_string()
456}