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 | |
| 12 | use crate::font::ShapedText; |
| 13 | use crate::ir::{ |
| 14 | Dims, |
| 15 | DrawOp, |
| 16 | Graphic, |
| 17 | Sp, |
| 18 | }; |
| 19 | |
| 20 | use oxedyne_fe2o3_core::prelude::*; |
| 21 | use oxedyne_fe2o3_font::{ |
| 22 | face::Role, |
| 23 | set::FontSet, |
| 24 | shape::Dir, |
| 25 | }; |
| 26 | use oxedyne_fe2o3_graphics::{ |
| 27 | colour::Rgba, |
| 28 | path::{ |
| 29 | PathBuilder, |
| 30 | Pt, |
| 31 | }, |
| 32 | transform::Transform, |
| 33 | }; |
| 34 | |
| 35 | use 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)] |
| 41 | pub 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)] |
| 49 | pub 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)] |
| 60 | pub 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 | |
| 74 | impl 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)] |
| 227 | pub 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 | |
| 238 | impl 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)`. |
| 320 | pub 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. |
| 352 | fn 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. |
| 360 | fn 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. |
| 371 | fn 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. |
| 382 | fn 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. |
| 398 | fn 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. |
| 434 | fn 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. |
| 449 | fn 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 | } |