Skip to main content

energyml_section/
layout.rs

1//! A catalogue and a view, laid out in numbers (ADR-0015).
2//!
3//! [`layout`] decides, and that is all that can be wrong in the drawing: the
4//! window and the scales, the **vertical exaggeration they result in**
5//! (written on the drawing), the graduations, what is drawn and in which order,
6//! the fills, the wells' colours and labels, the warnings. [`crate::svg`] only
7//! writes what is decided here - which is why `tests/layout.rs` asserts on
8//! numbers, and `tests/svg.rs` on the markup.
9//!
10//! Nothing is read and no geometry of the model is computed: a layout is
11//! cheap, and the front end redraws on every change of the view.
12//!
13//! # Draw order
14//!
15//! `envelopes` (the grids' fills, at the back) → `fills` (the surfaces')
16//! → `outlines` (the grids' tops, bottoms and faults) → `traces` → `wells` →
17//! `marks` (vertices, crossings) → `labels`. The layers are in [`Layout::layers`]
18//! in that order; the writer follows it.
19//!
20//! # What `View` does here
21//!
22//! * `hidden` - absent: no group, no label, no fill, and no fill *from* it nor
23//!   *towards* it (a hidden line is not a neighbour);
24//! * `highlight` - marked ([`LaidObject::highlighted`]), nothing else changes;
25//! * `portion` - a **truncation**: what falls in `[start, end]`, bounds
26//!   included, abscissas unchanged (*a well at 150 m stays at 150 m*);
27//!   a trace is cut at the bounds, a well is kept whole when its head's
28//!   abscissa is inside;
29//! * `window` - the viewport. `None`: everything, with a margin.
30//!
31//! # Axes
32//!
33//! `AxisMode::Projected` is drawn. `Coordinates` is drawn too - the trace is
34//! in the scene's system, so the true `(x, y)` at each tick is one
35//! `fence::point_at` away. `Geodesic` is **not**: it needs a scale factor along
36//! the trace from `energyml-proj` (and a reprojection when the scene's system
37//! is not the one the factor is known in), and this crate has no dependency on
38//! it. The layout falls back to the projected distance **and says so** in a
39//! warning ([`Layout::x_axis`]'s `mode` is the one actually drawn); the shape
40//! to fill - a function from `s` to a geodesic distance, applied to the tick
41//! values - is [`Axis`]: ticks carry their `value` and their `label`
42//! separately for that reason.
43
44pub(crate) mod fill;
45mod labels;
46mod marking;
47mod ticks;
48
49use std::collections::HashMap;
50
51use serde::{Deserialize, Serialize};
52
53use crate::model::{
54    AxisMode, Catalogue, CutSource, EnvelopeItem, FORMAT, FillChoice, Inputs, Interval, Item, LabelMode,
55    Mark, NEUTRAL_COLOUR, Outcome, Paint, Plan, Section, SectionKey, Side, Target, TraceItem, TraceRole,
56    UnstratifiedStyle, View, WellColouring, WellItem, Window,
57};
58use fill::Span;
59
60// ---------------------------------------------------------------------------
61// The layout
62
63/// Everything the SVG writer needs, decided: positions in pixels, ticks,
64/// fills, labels, warnings.
65#[derive(Clone, Debug, PartialEq)]
66pub struct Layout {
67    /// What is drawn.
68    pub target: Target,
69    /// The drawing's own title ("Long section", "Cross section 2", "Plan").
70    pub title: String,
71    /// Width and height, pixels.
72    pub size: [f64; 2],
73    /// The background colour.
74    pub background: String,
75    /// The plot area, pixels.
76    pub frame: Rect,
77    /// The window really drawn (what `View::window` asked, or everything with a
78    /// margin; in the plan, widened to the frame's proportions).
79    pub window: Window,
80    /// The window this drawing would have with `View::window` unset: the whole
81    /// extent. The same whatever window was asked, so a front end that holds a
82    /// zoomed drawing still knows what to clamp its zooms to.
83    pub whole: Window,
84    /// Pixels per unit of the window, `[horizontal, vertical]`.
85    pub scale: [f64; 2],
86    /// The vertical exaggeration those scales give - `scale[1] / scale[0]` -
87    /// for a section; `None` for the plan, which is equal on both axes.
88    pub exaggeration: Option<f64>,
89    /// What is written about it on the drawing: `"vertical exaggeration ×2.5"`.
90    pub exaggeration_text: String,
91    /// The position of the trace's first point (`s = 0`) with its EPSG code,
92    /// written at the centre of the title band - shortened to the code, or left
93    /// out (and counted in the warnings), when the band is too narrow. Empty
94    /// when the view asks for none.
95    pub origin_text: String,
96    /// Where that text starts, pixels: centred on the frame, clamped to the free
97    /// space between the title and the exaggeration.
98    pub origin_x: f64,
99    /// The horizontal axis.
100    pub x_axis: Axis,
101    /// The vertical axis.
102    pub y_axis: Axis,
103    /// The layers, back to front.
104    pub layers: Vec<Layer>,
105    /// What the drawing prints about itself: refused objects, wells set aside,
106    /// a projection in time, an axis it could not draw, labels folded.
107    pub warnings: Vec<String>,
108    /// How many labels did not fit and were folded.
109    pub folded_labels: usize,
110    /// How many lines of notes the drawing has room for under its plot -
111    /// what [`Layout::written_warnings`] writes. `0` when the view asks for no
112    /// notes.
113    pub note_lines: usize,
114    /// What the SVG's `<metadata>` carries.
115    pub metadata: Metadata,
116    /// Pixels kept on the left of the axes for the strata legend; `0` when there
117    /// is none. The axes' own furniture (the vertical title) moves by as much.
118    pub left_shift: f64,
119}
120
121/// A rectangle, pixels.
122#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
123pub struct Rect {
124    /// Left.
125    pub x: f64,
126    /// Top.
127    pub y: f64,
128    /// Width.
129    pub w: f64,
130    /// Height.
131    pub h: f64,
132}
133
134/// One axis: its title, the mode actually drawn, its ticks.
135#[derive(Clone, Debug, PartialEq)]
136pub struct Axis {
137    /// The title, with its unit.
138    pub title: String,
139    /// What it says - may differ from what the view asked (geodesic → projected).
140    pub mode: AxisMode,
141    /// The ticks, round numbers.
142    pub ticks: Vec<Tick>,
143}
144
145/// A graduation.
146#[derive(Clone, Debug, PartialEq)]
147pub struct Tick {
148    /// The value, in the window's unit.
149    pub value: f64,
150    /// Where it is, pixels (x for the horizontal axis, y for the vertical one).
151    pub at: f64,
152    /// Its text.
153    pub label: String,
154    /// A second line of text - the other coordinate on a `Coordinates` axis.
155    pub sublabel: Option<String>,
156}
157
158/// A group of objects drawn together, in one z-order slot.
159#[derive(Clone, Debug, PartialEq)]
160pub struct Layer {
161    /// `envelopes`, `fills`, `outlines`, `traces`, `wells`, `marks`, `labels`.
162    pub name: &'static str,
163    /// Whether the plot area clips it (labels above the frame are not).
164    pub clipped: bool,
165    /// Its groups.
166    pub objects: Vec<LaidObject>,
167}
168
169/// What a group is *about* - one attribute each, so that `data-key` stays the
170/// one-per-object handle the front end reads.
171#[derive(Clone, Debug, PartialEq)]
172pub enum Attr {
173    /// An object: its page key (`ObjectId::key`).
174    Key(String),
175    /// A section of the plan: `long`, `cross-2`.
176    Section(String),
177    /// The fill of an object's colour.
178    FillOf(String),
179    /// The label of an object.
180    LabelOf(String),
181    /// A decoration (a mark), named.
182    Decor(String),
183    /// A strata legend entry: the decoration's name, the full uri of the
184    /// unit it shows, and the packages whose wells show it in this colour -
185    /// what the front end opens the unit's colour by, and in which packages.
186    UnitEntry(String, String, Vec<String>),
187    /// A marker (`View::markers`): its name and, on the drawing, the key of the
188    /// object it marks - `data-marker` and `data-key`. In the legend, no key.
189    Marker {
190        /// The marker's name.
191        name: String,
192        /// The marked object's page key; `None` for a legend entry.
193        key: Option<String>,
194    },
195}
196
197/// A group of shapes.
198#[derive(Clone, Debug, PartialEq)]
199pub struct LaidObject {
200    /// What it is about.
201    pub attr: Attr,
202    /// `well`, `trace`, `envelope`, `fill`, `label`, `vertex`, `crossing`…
203    pub kind: String,
204    /// The object's title, as the package wrote it (the writer escapes it).
205    pub title: String,
206    /// Put forward on purpose (`View::highlight`).
207    pub highlighted: bool,
208    /// For a well: the head, in the window's units - where it was projected.
209    pub at: Option<[f64; 2]>,
210    /// What is drawn.
211    pub shapes: Vec<Shape>,
212    /// For a well: one group per interval, with its unit.
213    pub intervals: Vec<LaidInterval>,
214}
215
216/// One stratigraphic interval of a well, drawn.
217#[derive(Clone, Debug, PartialEq)]
218pub struct LaidInterval {
219    /// MD start.
220    pub from: f64,
221    /// MD end.
222    pub to: f64,
223    /// The unit's title.
224    pub unit: String,
225    /// The unit's full uri, when the interval names one - what the front end finds
226    /// the unit by (never its title).
227    pub unit_uri: Option<String>,
228    /// The colour drawn - the interval's own, or the well's when it has none.
229    pub colour: String,
230    /// The line's width, pixels - the well's.
231    pub width: f64,
232    /// The piece of path, pixels.
233    pub points: Vec<[f64; 2]>,
234    /// A short black line across the well where this interval begins against
235    /// the end of the one before it ([`View::strata_ticks`]): its two ends, pixels.
236    pub tick: Option<[[f64; 2]; 2]>,
237}
238
239/// Where a text sits against its anchor.
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub enum Anchor {
242    /// Starts at the point.
243    Start,
244    /// Centred on it.
245    Middle,
246    /// Ends at it.
247    End,
248}
249
250/// A primitive, in pixels. Colours are strings, written as attributes.
251#[derive(Clone, Debug, PartialEq)]
252pub enum Shape {
253    /// An open line.
254    Polyline {
255        /// Its points.
256        points: Vec<[f64; 2]>,
257        /// Colour.
258        stroke: String,
259        /// Width.
260        width: f64,
261        /// Dashed.
262        dash: bool,
263    },
264    /// An open line drawn dashed, with the lengths it was asked: a dash and a
265    /// gap in pixels (`stroke-dasharray`), butt caps so that a gap stays a gap.
266    Dashed {
267        /// Its points.
268        points: Vec<[f64; 2]>,
269        /// Colour.
270        stroke: String,
271        /// Width.
272        width: f64,
273        /// The length of a dash.
274        dash: f64,
275        /// The gap between two dashes.
276        gap: f64,
277    },
278    /// A closed area.
279    Polygon {
280        /// Its points.
281        points: Vec<[f64; 2]>,
282        /// Fill colour; `"none"` for an outline.
283        fill: String,
284        /// Fill opacity.
285        opacity: f64,
286        /// Outline colour, if any.
287        stroke: Option<String>,
288        /// Outline dashed.
289        dash: bool,
290    },
291    /// A disc.
292    Circle {
293        /// Centre.
294        c: [f64; 2],
295        /// Radius.
296        r: f64,
297        /// Fill colour.
298        fill: String,
299    },
300    /// A marker's symbol, centred on `at` and `size` pixels across: written as
301    /// paths, so nothing is loaded and nothing is stripped, and kept at its
302    /// pixel size by the zoom script (`data-zoom-fixed`) like a well's head.
303    Symbol {
304        /// Its centre.
305        at: [f64; 2],
306        /// The shape, one of [`crate::markers::declared`]'s ids that is drawn.
307        kind: String,
308        /// Its extent, pixels.
309        size: f64,
310        /// Fill colour; `"none"` for an outline.
311        fill: String,
312        /// Outline colour.
313        stroke: String,
314    },
315    /// A text.
316    Text {
317        /// Where.
318        at: [f64; 2],
319        /// What.
320        text: String,
321        /// Alignment.
322        anchor: Anchor,
323        /// Font size, pixels.
324        size: f64,
325        /// Colour.
326        fill: String,
327    },
328}
329
330/// What the SVG's `<metadata>` holds: enough to refuse by name
331/// and to redraw - the format, the version of this crate, the inputs (the trace
332/// itself, in coordinates and system) and the view.
333#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct Metadata {
336    /// [`FORMAT`].
337    pub format: u32,
338    /// This crate's version.
339    pub version: String,
340    /// What made the catalogue.
341    pub inputs: Inputs,
342    /// How it was looked at.
343    pub view: View,
344}
345
346impl Layout {
347    /// The warnings as they are written under the plot: every one when they
348    /// fit in [`Layout::note_lines`], else as many as fit less one, and a last
349    /// line counting the others - a note dropped is still said to exist.
350    pub fn written_warnings(&self) -> Vec<String> {
351        let room = self.note_lines;
352        if self.warnings.len() <= room {
353            return self.warnings.clone();
354        }
355        if room == 0 {
356            return Vec::new();
357        }
358        let mut out: Vec<String> = self.warnings[..room - 1].to_vec();
359        let hidden = self.warnings.len() - (room - 1);
360        out.push(if out.is_empty() {
361            format!("{hidden} notes - not shown at this size")
362        } else {
363            format!("+ {hidden} more notes - not shown at this size")
364        });
365        out
366    }
367
368    /// The front end keys of every object drawn, in drawing order - one per object,
369    /// whatever its layers (fills and labels are not keys).
370    pub fn keys(&self) -> Vec<&str> {
371        self.layers
372            .iter()
373            .flat_map(|l| l.objects.iter())
374            .filter_map(|o| {
375                if let Attr::Key(k) = &o.attr {
376                    Some(k.as_str())
377                } else {
378                    None
379                }
380            })
381            .collect()
382    }
383
384    /// A layer by name.
385    pub fn layer(&self, name: &str) -> Option<&Layer> {
386        self.layers.iter().find(|l| l.name == name)
387    }
388}
389
390// ---------------------------------------------------------------------------
391// Constants of the drawing
392
393const MARGIN_LEFT: f64 = 64.0;
394const MARGIN_RIGHT: f64 = 20.0;
395const MARGIN_TOP: f64 = 72.0;
396const MARGIN_BOTTOM: f64 = 46.0;
397const LINE_PX: f64 = 14.0;
398/// The plan's top margin: its title - its labels are inside its frame.
399const PLAN_TOP: f64 = 36.0;
400/// The smallest share of the drawing's height the plot keeps: notes are folded
401/// and the band of well labels lowered before the plot is squeezed under it
402/// (otherwise the top-view plot ends up squashed by the legends and the padding).
403const PLOT_SHARE: f64 = 0.5;
404/// The smallest plot, pixels, whatever the share.
405const PLOT_MIN_PX: f64 = 40.0;
406const FONT_PX: f64 = 11.0;
407const SMALL_PX: f64 = 10.0;
408const FILL_OPACITY: f64 = 0.35;
409
410const LEFT_COLOUR: &str = "#1f77b4";
411const RIGHT_COLOUR: &str = "#d95f02";
412const INK: &str = "#222222";
413const MARK_COLOUR: &str = "#7b1fa2";
414
415// ---------------------------------------------------------------------------
416// Entry
417
418/// Lay a catalogue out for a view. Reads nothing, computes no geometry of the
419/// model - only where things go.
420pub fn layout(catalogue: &Catalogue, view: &View) -> Layout {
421    match view.target {
422        Target::Section { key } => section(catalogue, view, key),
423        Target::Plan => plan(catalogue, view),
424    }
425}
426
427/// The front end's own metadata of a layout.
428fn metadata(catalogue: &Catalogue, view: &View) -> Metadata {
429    Metadata {
430        format: FORMAT,
431        version: env!("CARGO_PKG_VERSION").to_string(),
432        inputs: catalogue.inputs.clone(),
433        view: view.clone(),
434    }
435}
436
437fn unit_of(catalogue: &Catalogue) -> String {
438    catalogue
439        .inputs
440        .system
441        .as_ref()
442        .map(|s| s.unit.clone())
443        .unwrap_or_default()
444}
445
446/// Warnings common to every target - what the drawing owes its reader.
447fn common_warnings(catalogue: &Catalogue) -> Vec<String> {
448    let mut out = Vec::new();
449    let refused = catalogue
450        .objects
451        .iter()
452        .filter(|r| matches!(r.outcome, Outcome::Refused { .. }))
453        .count();
454    if refused > 0 {
455        out.push(format!("{refused} objects refused - see the objects list"));
456    }
457    let set_aside = catalogue
458        .plan
459        .wells
460        .iter()
461        .filter(|w| w.rejected.is_some())
462        .count();
463    if set_aside > 0 {
464        out.push(format!("{set_aside} wells set aside - projected nowhere"));
465    }
466    out
467}
468
469/// The pixel map of a window onto a frame.
470#[derive(Clone, Copy)]
471struct Map {
472    window: Window,
473    frame: Rect,
474    sx: f64,
475    sy: f64,
476}
477
478impl Map {
479    fn px(&self, p: [f64; 2]) -> [f64; 2] {
480        [
481            self.frame.x + (p[0] - self.window.x[0]) * self.sx,
482            self.frame.y + self.frame.h - (p[1] - self.window.y[0]) * self.sy,
483        ]
484    }
485    fn points(&self, pts: &[[f64; 2]]) -> Vec<[f64; 2]> {
486        dedupe(
487            pts.iter()
488                .filter(|p| p[0].is_finite() && p[1].is_finite())
489                .map(|&p| self.px(p))
490                .collect(),
491        )
492    }
493}
494
495/// Consecutive points closer than a twentieth of a pixel are one point: the
496/// SVG's size is the number of nodes, and a well of a thousand nodes at a
497/// small scale is a few dozen distinct pixels.
498fn dedupe(points: Vec<[f64; 2]>) -> Vec<[f64; 2]> {
499    let mut out: Vec<[f64; 2]> = Vec::with_capacity(points.len());
500    for p in points {
501        if out
502            .last()
503            .is_none_or(|q| (q[0] - p[0]).abs() >= 0.05 || (q[1] - p[1]).abs() >= 0.05)
504        {
505            out.push(p);
506        }
507    }
508    out
509}
510
511/// A growing bounding box.
512#[derive(Default)]
513struct Extent {
514    x: Option<[f64; 2]>,
515    y: Option<[f64; 2]>,
516}
517
518impl Extent {
519    fn add(&mut self, p: [f64; 2]) {
520        if !(p[0].is_finite() && p[1].is_finite()) {
521            return;
522        }
523        let grow = |r: &mut Option<[f64; 2]>, v: f64| {
524            *r = Some(match *r {
525                Some([a, b]) => [a.min(v), b.max(v)],
526                None => [v, v],
527            })
528        };
529        grow(&mut self.x, p[0]);
530        grow(&mut self.y, p[1]);
531    }
532    fn add_all(&mut self, pts: &[[f64; 2]]) {
533        pts.iter().for_each(|&p| self.add(p));
534    }
535}
536
537/// A range made drawable: a margin, and never a zero width.
538fn padded(range: Option<[f64; 2]>, margin: f64) -> [f64; 2] {
539    let [a, b] = range.unwrap_or([0.0, 1.0]);
540    let span = b - a;
541    if span <= f64::EPSILON * a.abs().max(1.0) {
542        return [a - 0.5, b + 0.5];
543    }
544    [a - span * margin, b + span * margin]
545}
546
547/// The plot area: the drawing less its margins, `lines` of warnings below, and
548/// `right` pixels kept on the right (the legend of codes) and `left` on the
549/// left of the axes (the strata legend).
550fn frame_of(view: &View, lines: usize, right: f64, left: f64, top: f64) -> Rect {
551    let [w, h] = view.size_px;
552    let bottom = MARGIN_BOTTOM + LINE_PX * lines as f64;
553    Rect {
554        x: MARGIN_LEFT + left,
555        y: top,
556        w: (w - MARGIN_LEFT - left - MARGIN_RIGHT - right).max(10.0),
557        h: (h - top - bottom).max(10.0),
558    }
559}
560
561/// How a drawing of height `h` shares itself out: the top margin for `levels`
562/// levels of well labels (`top_for`), and the lines of notes. Notes go first -
563/// down to one, the line that counts them - then levels of labels down to one,
564/// until the plot keeps [`PLOT_SHARE`] of the height. Returns the top margin,
565/// the levels kept and the lines of notes.
566fn budget(
567    h: f64,
568    wanted_levels: usize,
569    wanted_lines: usize,
570    top_for: impl Fn(usize) -> f64,
571) -> (f64, usize, usize) {
572    let floor = (h * PLOT_SHARE).max(PLOT_MIN_PX);
573    let (mut levels, mut lines) = (wanted_levels, wanted_lines);
574    loop {
575        let plot = h - top_for(levels) - MARGIN_BOTTOM - LINE_PX * lines as f64;
576        if plot >= floor {
577            break;
578        }
579        if lines > 1 {
580            lines -= 1;
581        } else if levels > 1 {
582            levels -= 1;
583        } else {
584            break;
585        }
586    }
587    (top_for(levels), levels, lines)
588}
589
590fn is_hidden(view: &View, key: &str) -> bool {
591    view.hidden.iter().any(|h| h == key)
592}
593
594/// The width an object is drawn at: the view's own for it when it is valid,
595/// else `default`. A refused width is not drawn here - [`width_warnings`] names it.
596fn width_of(view: &View, key: &str, default: f64) -> f64 {
597    view.widths
598        .iter()
599        .find(|(k, _)| k == key)
600        .map(|(_, w)| *w)
601        .filter(|w| valid_width(*w))
602        .unwrap_or(default)
603}
604
605fn valid_width(w: f64) -> bool {
606    w.is_finite() && w > 0.0 && w <= crate::model::MAX_WIDTH_PX
607}
608
609/// One warning per width the view asks for that cannot be drawn - never a
610/// silent clamp. The object is named by its uri (the key's store part dropped).
611fn width_warnings(view: &View) -> Vec<String> {
612    view.widths
613        .iter()
614        .filter(|(_, w)| !valid_width(*w))
615        .map(|(k, w)| {
616            let uri = k.split('\0').next_back().unwrap_or(k);
617            format!("line width {w} refused for {uri} - it must be above 0 and at most {} px; the default is drawn", crate::model::MAX_WIDTH_PX)
618        })
619        .collect()
620}
621
622pub(crate) fn colour_of(view: &View, key: &str) -> Option<String> {
623    view.colours
624        .iter()
625        .find(|(k, _)| k == key)
626        .map(|(_, c)| c.clone())
627}
628
629// ---------------------------------------------------------------------------
630// A section
631
632fn key_text(key: SectionKey) -> String {
633    match key {
634        SectionKey::Long => "long".to_string(),
635        SectionKey::Cross { index } => format!("cross-{index}"),
636    }
637}
638
639fn find_section(catalogue: &Catalogue, key: SectionKey) -> Option<&Section> {
640    match key {
641        SectionKey::Long => Some(&catalogue.long),
642        SectionKey::Cross { .. } => catalogue.crosses.iter().find(|s| s.key == key),
643    }
644}
645
646/// How far a span runs along the section.
647fn span_extent(span: &[[f64; 2]]) -> f64 {
648    match (span.first(), span.last()) {
649        (Some(a), Some(b)) => (b[0] - a[0]).abs(),
650        _ => 0.0,
651    }
652}
653
654/// The colour of a surface neither the scene nor the file colours: the hue the
655/// 3D view gives it, derived from its uuid (`energyml_domain::color::derived_colour`,
656/// the front end's `colourFor`) - so a drawing without a scene shows the surfaces in
657/// the colours the 3D view shows them, rather than one colour for all. An object with no uuid takes its role's.
658pub(crate) fn fallback_colour(uuid: &str, role: TraceRole) -> String {
659    if uuid.is_empty() {
660        default_trace_colour(role).to_string()
661    } else {
662        energyml_domain::color::derived_colour(uuid)
663    }
664}
665
666/// A grid envelope's colour: the scene's, the file's, then the 3D view's hue.
667fn envelope_colour(view: &View, e: &EnvelopeItem) -> String {
668    colour_of(view, &e.object.key())
669        .or_else(|| e.colour.clone())
670        .unwrap_or_else(|| fallback_colour(&e.object.uuid, TraceRole::Other))
671}
672
673/// A surface's colour: the scene's first (the front end's stack), then the one the
674/// file gives, then the 3D view's hue - never a colour invented from its name.
675/// **One resolution** for the drawing and the 3D panels (`panels.rs`): two
676/// would let a panel and the line it fills from disagree.
677pub(crate) fn trace_colour(view: &View, t: &TraceItem) -> String {
678    colour_of(view, &t.object.key())
679        .or_else(|| t.colour.clone())
680        .unwrap_or_else(|| fallback_colour(&t.object.uuid, t.role))
681}
682
683fn default_trace_colour(role: TraceRole) -> &'static str {
684    match role {
685        TraceRole::Horizon => "#2e7d32",
686        TraceRole::Fault => "#c62828",
687        TraceRole::Other => NEUTRAL_COLOUR,
688    }
689}
690
691/// A trace kept for drawing: its object, its colour, its spans cut to the
692/// portion.
693struct KeptTrace<'a> {
694    item: &'a TraceItem,
695    key: String,
696    colour: String,
697    spans: Vec<Span>,
698}
699
700fn section(catalogue: &Catalogue, view: &View, key: SectionKey) -> Layout {
701    let mut warnings = common_warnings(catalogue);
702    warnings.extend(width_warnings(view));
703    let unit = unit_of(catalogue);
704    let title = match key {
705        SectionKey::Long => "Long section".to_string(),
706        SectionKey::Cross { index } => format!("Cross section {index}"),
707    };
708    let sec = find_section(catalogue, key);
709    if sec.is_none() {
710        warnings.push(format!("{title}: not in the catalogue - nothing to draw"));
711    }
712    let empty = Section {
713        key,
714        trace: Vec::new(),
715        length: 0.0,
716        marks: Vec::new(),
717        band: Vec::new(),
718        items: Vec::new(),
719        z_range: None,
720    };
721    let sec = sec.unwrap_or(&empty);
722
723    // The portion: a truncation, abscissas unchanged.
724    let portion = view.portion.map(|[a, b]| if a <= b { [a, b] } else { [b, a] });
725    let (plo, phi) = portion.map_or((f64::NEG_INFINITY, f64::INFINITY), |[a, b]| (a, b));
726    let in_portion = |s: f64| s >= plo && s <= phi;
727
728    // What is kept.
729    let mut wells: Vec<&WellItem> = Vec::new();
730    let mut traces: Vec<KeptTrace> = Vec::new();
731    // An envelope, its top and bottom kept within the window, and its fills and faults.
732    #[allow(clippy::type_complexity)]
733    let mut envelopes: Vec<(
734        &EnvelopeItem,
735        Vec<Span>,
736        Vec<Span>,
737        Vec<Vec<[f64; 2]>>,
738        Vec<Vec<[f64; 2]>>,
739    )> = Vec::new();
740    let mut time_drawn = false;
741    for item in &sec.items {
742        match item {
743            Item::Well(w) => {
744                let k = w.object.key();
745                if !is_hidden(view, &k) && in_portion(w.s) {
746                    wells.push(w);
747                }
748            }
749            Item::Trace(t) => {
750                let k = t.object.key();
751                if is_hidden(view, &k) {
752                    continue;
753                }
754                let spans: Vec<Span> = t
755                    .spans
756                    .iter()
757                    .filter_map(|s| fill::clip_span(s, plo, phi))
758                    .collect();
759                if spans.is_empty() {
760                    continue;
761                }
762                time_drawn |= t.time;
763                // The scene's colour first (the front end's stack), then the one the
764                // file gives, then the role's - never a colour invented in the
765                // file's name.
766                let colour = trace_colour(view, t);
767                traces.push(KeptTrace {
768                    item: t,
769                    key: k,
770                    colour,
771                    spans,
772                });
773            }
774            Item::Envelope(e) => {
775                let k = e.object.key();
776                if is_hidden(view, &k) {
777                    continue;
778                }
779                let top: Vec<Span> = e
780                    .top
781                    .iter()
782                    .filter_map(|s| fill::clip_span(s, plo, phi))
783                    .collect();
784                let bottom: Vec<Span> = e
785                    .bottom
786                    .iter()
787                    .filter_map(|s| fill::clip_span(s, plo, phi))
788                    .collect();
789                let fills: Vec<_> = e
790                    .fills
791                    .iter()
792                    .map(|p| fill::clip_polygon(p, plo, phi))
793                    .filter(|p| !p.is_empty())
794                    .collect();
795                let faults: Vec<_> = e
796                    .faults
797                    .iter()
798                    .filter_map(|s| fill::clip_span(s, plo, phi))
799                    .collect();
800                if top.is_empty() && bottom.is_empty() && fills.is_empty() && faults.is_empty() {
801                    continue;
802                }
803                time_drawn |= e.time;
804                envelopes.push((e, top, bottom, fills, faults));
805            }
806        }
807    }
808    if time_drawn {
809        warnings.push(match catalogue.inputs.time_factor {
810            Some(f) => {
811                format!("surfaces in time are drawn on the depth axis at {f} depth units per time unit")
812            }
813            None => "surfaces in time are drawn on the depth axis without a time scale".to_string(),
814        });
815    }
816    // What the catalogue noticed about this section, one line per kind.
817    for (code, one, many) in [
818        (crate::erosion::CODE, "erosion suspected", "erosions suspected"),
819        ("crossingMismatch", "crossing mismatch", "crossing mismatches"),
820    ] {
821        let count = catalogue
822            .notes
823            .iter()
824            .filter(|n| n.code == code && n.sections.contains(&key))
825            .count();
826        if count > 0 {
827            warnings.push(format!(
828                "{count} {} - see the notes",
829                if count == 1 { one } else { many }
830            ));
831        }
832    }
833
834    // The window.
835    // Computed whatever the view asks: `whole` is written on the drawing.
836    let whole = {
837        let mut e = Extent::default();
838        e.add([portion.map_or(0.0, |p| p[0]), 0.0]);
839        e.add([portion.map_or(sec.length, |p| p[1]), 0.0]);
840        let mut z = Extent::default();
841        for w in &wells {
842            z.add(w.head);
843            z.add_all(&w.path);
844        }
845        for t in &traces {
846            t.spans.iter().for_each(|s| z.add_all(s));
847        }
848        for (_, top, bottom, fills, faults) in &envelopes {
849            top.iter().chain(bottom).chain(faults).for_each(|s| z.add_all(s));
850            fills.iter().for_each(|s| z.add_all(s));
851        }
852        Window {
853            x: padded(e.x, 0.02),
854            y: padded(z.y, 0.05),
855        }
856    };
857    let window = view.window.unwrap_or(whole);
858    // Wells wholly outside a given window are not written (the cost of a drawing is its nodes).
859    wells.retain(|w| {
860        view.window.is_none()
861            || w.path
862                .iter()
863                .chain(std::iter::once(&w.head))
864                .any(|p| p[0] >= window.x[0] && p[0] <= window.x[1])
865    });
866
867    // Axes: what can be drawn.
868    let mut mode = view.axis;
869    if mode == AxisMode::Geodesic {
870        warnings.push(
871            "geodesic axis not available: the horizontal axis shows the projected distance \
872             (a scale factor along the trace from energyml-proj is needed)"
873                .to_string(),
874        );
875        mode = AxisMode::Projected;
876    }
877    // A paint the scene gives a well that cannot be laid on it is said now, so
878    // that its line has its room under the plot; the well is drawn as before.
879    for w in &wells {
880        if let Some((paint, _)) = paint_of(view, w)
881            && let Err(why) = crate::paint::check(w.path.len(), paint, &view.joints)
882        {
883            warnings.push(paint_refusal(w, &why));
884        }
885    }
886    // The strata legend: what the wells could show, decided before the frame
887    // since it takes its room on the left. What is *drawn* is read back from the
888    // laid wells below, so that it is never a second reading of the colours.
889    let strata = if view.strata_legend {
890        strata_units(&wells, view)
891    } else {
892        Vec::new()
893    };
894    if view.strata_legend && strata.is_empty() {
895        warnings.push(strata_refusal(&wells, view));
896    }
897    let unstratified = effective_style(view, &mut warnings);
898    if unstratified == UnstratifiedStyle::Hidden && view.well_colouring == WellColouring::Strata {
899        let lost = wells
900            .iter()
901            .filter(|w| !painted(view, w) && has_unstratified(w))
902            .count();
903        if lost > 0 {
904            warnings.push(format!(
905                "{lost} well(s) drawn only where a stratigraphic unit is known - the portions with no strata information are not drawn"
906            ));
907        }
908    }
909    // A style that is neither the default nor hidden would be read as a unit by
910    // someone who does not know it: the legend says what it is.
911    let no_strata = (view.strata_legend
912        && !strata.is_empty()
913        && unstratified != UnstratifiedStyle::Hidden
914        && !unstratified.is_default()
915        && wells.iter().any(|w| !painted(view, w) && has_unstratified(w)))
916    .then(|| unstratified.clone());
917    // The markers: decided in the drawing's units, before the frame, because the
918    // legend takes its room on the right.
919    let marked = marking::resolve(view, &wells, &traces, &mut warnings);
920    let strata_w = strata_width(&strata, no_strata.is_some(), view);
921    let left_shift = if strata_w > 0.0 {
922        strata_w + STRATA_GAP_PX
923    } else {
924        0.0
925    };
926    let labelled = view.show.names || view.show.distances;
927    // One line more than the warnings known now: the labels folded are only
928    // known once they are placed.
929    let reserved = if view.show.notes {
930        warnings.len() + usize::from(labelled && !wells.is_empty())
931    } else {
932        0
933    };
934    // Names: in codes mode, the codes and their legend - decided before the
935    // frame, since the legend takes its room on the right.
936    let coded = (view.labels == LabelMode::Codes).then(|| codes(catalogue));
937    let mut entries: Vec<LegendEntry> = Vec::new();
938    if let Some(coded) = &coded {
939        for w in &wells {
940            if let Some(code) = coded.get(&w.object.key()) {
941                entries.push(LegendEntry {
942                    code: code.clone(),
943                    title: well_name(&w.object),
944                    colour: NEUTRAL_COLOUR.into(),
945                    round: true,
946                });
947            }
948        }
949        for t in &traces {
950            if let Some(code) = coded.get(&t.key) {
951                entries.push(LegendEntry {
952                    code: code.clone(),
953                    title: t.item.object.title.clone(),
954                    colour: t.colour.clone(),
955                    round: false,
956                });
957            }
958        }
959        for (e, ..) in &envelopes {
960            if let Some(code) = coded.get(&e.object.key()) {
961                entries.push(LegendEntry {
962                    code: code.clone(),
963                    title: e.object.title.clone(),
964                    colour: envelope_colour(view, e),
965                    round: false,
966                });
967            }
968        }
969    }
970    let legend_w = legend_width(&entries);
971    let markers_w = marked.width();
972    let right_w = legend_w.max(markers_w);
973    // The band above the plot is the wells' labels': room for
974    // [`HEAD_BAND_LEVELS`] levels of them, so a crowded line of heads climbs
975    // rather than spilling into the drawing.
976    let label_lines = usize::from(view.show.names) + usize::from(view.show.distances);
977    let banded = label_lines > 0 && !wells.is_empty();
978    let line_h = (label_lines as f64 - 1.0) * LINE_PX + FONT_PX + 3.0;
979    let top_for = |levels: usize| {
980        if banded {
981            TITLE_BAND_PX + levels as f64 * (line_h + 3.0) + 8.0
982        } else {
983            MARGIN_TOP
984        }
985    };
986    let (top, _, note_lines) = budget(view.size_px[1], HEAD_BAND_LEVELS, reserved, top_for);
987    let frame = frame_of(
988        view,
989        note_lines,
990        if right_w > 0.0 { right_w + 12.0 } else { 0.0 },
991        left_shift,
992        top,
993    );
994    let sx = frame.w / (window.x[1] - window.x[0]);
995    let sy = frame.h / (window.y[1] - window.y[0]);
996    let map = Map {
997        window,
998        frame,
999        sx,
1000        sy,
1001    };
1002    let exaggeration = sy / sx;
1003    let exaggeration_text = format!("vertical exaggeration ×{}", round_sig(exaggeration));
1004    let (origin_text, origin_x) = origin_line(
1005        view.show.origin,
1006        sec.trace.first().copied(),
1007        catalogue.inputs.system.as_ref(),
1008        &title,
1009        &exaggeration_text,
1010        &frame,
1011        &mut warnings,
1012    );
1013
1014    let mut layers = Vec::new();
1015
1016    // envelopes - the grids' fills, at the very back; their outlines come
1017    // after every fill (`outlines`), so that nothing coloured covers a line
1018    // (objects with a coloured background go to the back of the drawing so that the
1019    // others stay visible).
1020    let mut env_fills = Vec::new();
1021    let mut env_lines = Vec::new();
1022    for (e, top, bottom, fills, faults) in &envelopes {
1023        let key = e.object.key();
1024        let colour = envelope_colour(view, e);
1025        let polygons: Vec<Shape> = fills
1026            .iter()
1027            .map(|poly| Shape::Polygon {
1028                points: map.points(poly),
1029                fill: colour.clone(),
1030                opacity: FILL_OPACITY,
1031                stroke: None,
1032                dash: false,
1033            })
1034            .collect();
1035        if !polygons.is_empty() {
1036            env_fills.push(LaidObject {
1037                attr: Attr::FillOf(key.clone()),
1038                kind: "fill".into(),
1039                title: e.object.title.clone(),
1040                highlighted: false,
1041                at: None,
1042                shapes: polygons,
1043                intervals: Vec::new(),
1044            });
1045        }
1046        let lines: Vec<Shape> = top
1047            .iter()
1048            .chain(bottom)
1049            .chain(faults)
1050            .map(|line| Shape::Polyline {
1051                points: map.points(line),
1052                stroke: colour.clone(),
1053                width: width_of(view, &key, 1.0),
1054                dash: false,
1055            })
1056            .collect();
1057        env_lines.push(LaidObject {
1058            highlighted: view.highlight.contains(&key),
1059            attr: Attr::Key(key),
1060            kind: "envelope".into(),
1061            title: e.object.title.clone(),
1062            at: None,
1063            shapes: lines,
1064            intervals: Vec::new(),
1065        });
1066    }
1067    layers.push(Layer {
1068        name: "envelopes",
1069        clipped: true,
1070        objects: env_fills,
1071    });
1072
1073    // fills - a trace to its neighbour, per the view's choices.
1074    let lines: Vec<fill::Line<'_>> = traces
1075        .iter()
1076        .map(|t| fill::Line {
1077            key: &t.key,
1078            item: t.item,
1079            spans: &t.spans,
1080        })
1081        .collect();
1082    let mut fill_objects = Vec::new();
1083    for (t, mine) in traces.iter().zip(&lines) {
1084        let Some(FillChoice { up, down, .. }) = view.fills.iter().find(|f| f.key == t.key) else {
1085            continue;
1086        };
1087        let mut shapes = Vec::new();
1088        for (wanted, is_up) in [(*up, true), (*down, false)] {
1089            if !wanted {
1090                continue;
1091            }
1092            for poly in fill::polygons(mine, &lines, is_up) {
1093                shapes.push(Shape::Polygon {
1094                    points: map.points(&poly),
1095                    fill: t.colour.clone(),
1096                    opacity: FILL_OPACITY,
1097                    stroke: None,
1098                    dash: false,
1099                });
1100            }
1101        }
1102        if !shapes.is_empty() {
1103            fill_objects.push(LaidObject {
1104                attr: Attr::FillOf(t.key.clone()),
1105                kind: "fill".into(),
1106                title: t.item.object.title.clone(),
1107                highlighted: false,
1108                at: None,
1109                shapes,
1110                intervals: Vec::new(),
1111            });
1112        }
1113    }
1114    layers.push(Layer {
1115        name: "fills",
1116        clipped: true,
1117        objects: fill_objects,
1118    });
1119    layers.push(Layer {
1120        name: "outlines",
1121        clipped: true,
1122        objects: env_lines,
1123    });
1124
1125    // erosions - a background of the lines, so it sits before them; a section
1126    // with none suspected adds no layer, and its drawing is what it was.
1127    let erosion_objects = erosion_marks(sec, &traces, [plo, phi], &map);
1128    if !erosion_objects.is_empty() {
1129        layers.push(Layer {
1130            name: "erosions",
1131            clipped: true,
1132            objects: erosion_objects,
1133        });
1134    }
1135
1136    // traces
1137    let mut trace_objects: Vec<LaidObject> = Vec::new();
1138    let mut names: Vec<LineName> = Vec::new();
1139    for t in &traces {
1140        let highlighted = view.highlight.contains(&t.key);
1141        let line_width = width_of(view, &t.key, 1.5) + if highlighted { 1.5 } else { 0.0 };
1142        // What the scene paints along the surface, when its cut says where
1143        // each point comes from: per span, else the flat colour as before.
1144        let paint = view
1145            .paints
1146            .iter()
1147            .find(|(k, _)| *k == t.key)
1148            .map(|(_, paint)| paint);
1149        let sources = cut_sources(t.item);
1150        let mut paint_refusal: Option<String> = None;
1151        let mut shapes: Vec<Shape> = Vec::new();
1152        for s in &t.spans {
1153            if let (Some(paint), Some(sources)) = (paint, sources)
1154                && let Some(from) = source_of_span(t.item, sources, s)
1155            {
1156                let px: Vec<[f64; 2]> = s.iter().map(|&p| map.px(p)).collect();
1157                if px.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
1158                    match crate::paint::surfaces::trace_pieces(&px, from, paint) {
1159                        Ok(pieces) => {
1160                            shapes.extend(pieces.into_iter().map(|piece| Shape::Polyline {
1161                                stroke: crate::paint::over(&piece.colour, piece.opacity, &view.background),
1162                                points: piece.points,
1163                                width: line_width,
1164                                dash: false,
1165                            }));
1166                            continue;
1167                        }
1168                        Err(why) => {
1169                            paint_refusal.get_or_insert(why);
1170                        }
1171                    }
1172                }
1173            }
1174            shapes.push(Shape::Polyline {
1175                points: map.points(s),
1176                stroke: t.colour.clone(),
1177                width: line_width,
1178                dash: false,
1179            });
1180        }
1181        if let Some(why) = paint_refusal {
1182            warnings.push(format!(
1183                "scene colours refused for {} - {why}; the surface is drawn in its own colour",
1184                t.item.object.title
1185            ));
1186        }
1187        // Its name at the right end of its longest span, in its own colour: a
1188        // section of ten grey-green lines reads as one, and the eye looks for
1189        // the name at the end the line runs out on. Placed below, all together,
1190        // so two names never sit on each other (`line_names`).
1191        if view.show.surface_names
1192            && let Some(longest) = t
1193                .spans
1194                .iter()
1195                .max_by(|a, b| span_extent(a).total_cmp(&span_extent(b)))
1196            && let Some(&end) = longest.last()
1197            && let Some(&end) = map.points(&[end]).first()
1198        {
1199            names.push(LineName {
1200                owner: trace_objects.len(),
1201                end,
1202                text: shown_name(coded.as_ref(), &t.item.object),
1203                colour: t.colour.clone(),
1204            });
1205        }
1206        trace_objects.push(LaidObject {
1207            attr: Attr::Key(t.key.clone()),
1208            kind: match t.item.role {
1209                TraceRole::Horizon => "horizon",
1210                TraceRole::Fault => "fault",
1211                TraceRole::Other => "surface",
1212            }
1213            .into(),
1214            title: t.item.object.title.clone(),
1215            highlighted,
1216            at: None,
1217            shapes,
1218            intervals: Vec::new(),
1219        });
1220    }
1221    // marks
1222    let mut mark_shapes = Vec::new();
1223    let mut mark_placer = labels::Placer::new(labels::Rect2 {
1224        x0: frame.x,
1225        y0: frame.y,
1226        x1: frame.x + frame.w,
1227        y1: frame.y + frame.h,
1228    });
1229    let mut marks_unlabelled = 0usize;
1230    for mark in &sec.marks {
1231        let (s, kind, text, show) = match *mark {
1232            Mark::Vertex { s, index } => (s, "vertex", format!("V{index}"), view.show.vertices),
1233            Mark::Crossing { s, other } => {
1234                let text = match other {
1235                    SectionKey::Long => "× long".to_string(),
1236                    SectionKey::Cross { index } => format!("× cross {index}"),
1237                };
1238                (s, "crossing", text, view.show.crossings)
1239            }
1240        };
1241        if !show || !in_portion(s) || s < window.x[0] || s > window.x[1] {
1242            continue;
1243        }
1244        let x = map.px([s, 0.0])[0];
1245        let (y0, y1) = if kind == "vertex" {
1246            (frame.y + frame.h - 8.0, frame.y + frame.h)
1247        } else {
1248            (frame.y, frame.y + frame.h)
1249        };
1250        mark_shapes.push(Shape::Polyline {
1251            points: vec![[x, y0], [x, y1]],
1252            stroke: MARK_COLOUR.into(),
1253            width: 1.0,
1254            dash: kind == "crossing",
1255        });
1256        // Its text, in the first place that is free (on a
1257        // phone, nine crossings on 2 160 m wrote "× cross 0 × cross 1 …" on
1258        // each other). In order of preference, level by level from the edge
1259        // the text hugs: the whole text on the right of the line, then on its
1260        // left, then the short form on the right and on the left - a short text
1261        // on its own level reads better than a long one stacked into the plot,
1262        // where it covers what is drawn. The line is always drawn: a mark with
1263        // no place keeps it and loses only its text, and that is counted.
1264        let base_y = if kind == "vertex" {
1265            frame.y + frame.h - 10.0
1266        } else {
1267            frame.y + SMALL_PX + 2.0
1268        };
1269        let step = if kind == "vertex" {
1270            -MARK_LEVEL_PX
1271        } else {
1272            MARK_LEVEL_PX
1273        };
1274        let mut forms = vec![text.clone()];
1275        let short = short_mark_text(&text);
1276        if short != text {
1277            forms.push(short);
1278        }
1279        let mut candidates: Vec<(String, labels::Rect2, [f64; 2], Anchor)> = Vec::new();
1280        for level in 0..MARK_LEVELS {
1281            let y = base_y + step * level as f64;
1282            for form in &forms {
1283                let w = labels::width_px(form, SMALL_PX);
1284                for (text_x, anchor, x0) in [
1285                    (x + 3.0, Anchor::Start, x + 3.0),
1286                    (x - 3.0, Anchor::End, x - 3.0 - w),
1287                ] {
1288                    // The same box `text_rects` reads back for this text.
1289                    let rect = labels::Rect2 {
1290                        x0,
1291                        y0: y - SMALL_PX,
1292                        x1: x0 + w,
1293                        y1: y + 2.0,
1294                    };
1295                    candidates.push((form.clone(), rect, [text_x, y], anchor));
1296                }
1297            }
1298        }
1299        let rects: Vec<labels::Rect2> = candidates.iter().map(|c| c.1).collect();
1300        match mark_placer.place(&rects) {
1301            Some(at) => {
1302                let (text, _, at, anchor) = candidates.swap_remove(at);
1303                mark_shapes.push(Shape::Text {
1304                    at,
1305                    text,
1306                    anchor,
1307                    size: SMALL_PX,
1308                    fill: MARK_COLOUR.into(),
1309                });
1310            }
1311            None => marks_unlabelled += 1,
1312        }
1313    }
1314    if marks_unlabelled > 0 {
1315        warnings.push(format!(
1316            "{marks_unlabelled} section marks unlabelled - they did not fit"
1317        ));
1318    }
1319    let marks_group = if mark_shapes.is_empty() {
1320        Vec::new()
1321    } else {
1322        vec![LaidObject {
1323            attr: Attr::Decor("marks".into()),
1324            kind: "marks".into(),
1325            title: String::new(),
1326            highlighted: false,
1327            at: None,
1328            shapes: mark_shapes,
1329            intervals: Vec::new(),
1330        }]
1331    };
1332    // The marks' texts are where they are: the names placed after them keep clear.
1333    let mark_texts = text_rects(marks_group.iter().flat_map(|o| o.shapes.iter()));
1334    let bounds = labels::Rect2 {
1335        x0: frame.x,
1336        y0: frame.y,
1337        x1: frame.x + frame.w,
1338        y1: frame.y + frame.h,
1339    };
1340    let (placed, names_folded) = line_names(names, bounds, &mark_texts);
1341    for (owner, text) in placed {
1342        trace_objects[owner].shapes.push(text);
1343    }
1344    if names_folded > 0 {
1345        warnings.push(format!("{names_folded} surface names folded - they did not fit"));
1346    }
1347    // A contour is a line under the lines it marks: behind the traces and the wells.
1348    let contours = marked.contours(&map);
1349    if !contours.is_empty() {
1350        layers.push(Layer {
1351            name: "marker-contours",
1352            clipped: true,
1353            objects: contours,
1354        });
1355    }
1356    layers.push(Layer {
1357        name: "traces",
1358        clipped: true,
1359        objects: trace_objects,
1360    });
1361
1362    // wells
1363    let mut well_objects = Vec::new();
1364    for w in &wells {
1365        well_objects.push(laid_well(w, view, &unstratified, &map, &unit, &mut warnings));
1366    }
1367    // What the wells drew, read back for the strata legend: the units and the
1368    // colours of the intervals as laid.
1369    let drawn_units: std::collections::HashSet<(String, String)> = well_objects
1370        .iter()
1371        .flat_map(|o| o.intervals.iter())
1372        .map(|iv| (iv.unit.clone(), iv.colour.clone()))
1373        .collect();
1374    layers.push(Layer {
1375        name: "wells",
1376        clipped: true,
1377        objects: well_objects,
1378    });
1379
1380    let symbols = marked.symbols(&map);
1381    if !symbols.is_empty() {
1382        layers.push(Layer {
1383            name: "markers",
1384            clipped: true,
1385            objects: symbols,
1386        });
1387    }
1388
1389    layers.push(Layer {
1390        name: "marks",
1391        clipped: true,
1392        objects: marks_group,
1393    });
1394
1395    // labels
1396    let (label_objects, folded) = well_labels(&wells, view, &map, &unit, coded.as_ref(), &mark_texts);
1397    layers.push(Layer {
1398        name: "labels",
1399        clipped: false,
1400        objects: label_objects,
1401    });
1402    let legend_box = legend(
1403        entries,
1404        frame.x + frame.w + 12.0,
1405        legend_w,
1406        frame.y,
1407        frame.y + frame.h,
1408    );
1409    // The legend of markers goes on the right, **under** the legend of codes: that
1410    // margin is already reserved for a legend, the strata legend owns the left, and
1411    // the drawing's own frame stays clear.
1412    let codes_bottom = legend_box.as_ref().and_then(marking::bottom_of);
1413    layers.push(Layer {
1414        name: "legend",
1415        clipped: false,
1416        objects: legend_box.into_iter().collect(),
1417    });
1418    if !marked.is_empty() {
1419        let top = codes_bottom.map_or(frame.y, |b| b + 8.0);
1420        let objects = marked.legend(frame.x + frame.w + 12.0, markers_w, top, frame.y + frame.h);
1421        layers.push(Layer {
1422            name: MARKERS_LAYER,
1423            clipped: false,
1424            objects,
1425        });
1426    }
1427    if folded > 0 {
1428        warnings.push(format!("{folded} well labels folded - they did not fit"));
1429    }
1430    if view.strata_legend {
1431        let shown: Vec<StrataEntry> = strata
1432            .into_iter()
1433            .filter(|e| drawn_units.contains(&(e.unit.clone(), e.colour.clone())))
1434            .collect();
1435        let objects = strata_legend(
1436            &shown,
1437            view,
1438            no_strata.as_ref(),
1439            STRATA_LEFT_PX,
1440            strata_w,
1441            frame.y,
1442            frame.y + frame.h,
1443        );
1444        layers.push(Layer {
1445            name: STRATA_LAYER,
1446            clipped: false,
1447            objects,
1448        });
1449    }
1450
1451    // axes
1452    let x_axis = x_axis(sec, &map, mode, &unit);
1453    let y_axis = y_axis(&map, &unit, "z");
1454
1455    Layout {
1456        target: view.target,
1457        title,
1458        size: view.size_px,
1459        background: view.background.clone(),
1460        frame,
1461        window,
1462        whole,
1463        scale: [sx, sy],
1464        exaggeration: Some(exaggeration),
1465        exaggeration_text,
1466        origin_text,
1467        origin_x,
1468        x_axis,
1469        y_axis,
1470        layers,
1471        warnings,
1472        folded_labels: folded,
1473        note_lines,
1474        metadata: metadata(catalogue, view),
1475        left_shift,
1476    }
1477}
1478
1479/// The colour of a suspected erosion: its own, apart from every surface's.
1480const EROSION_COLOUR: &str = "#d81b60";
1481
1482/// The suspected erosions of a section, laid out: the lens between the two
1483/// surfaces over the portion, and the older surface thickened there - it is the
1484/// one that should stop. Only what is drawn counts: a hidden surface marks
1485/// nothing, and a portion cut to the view's `portion`.
1486fn erosion_marks(
1487    sec: &Section,
1488    traces: &[KeptTrace<'_>],
1489    [plo, phi]: [f64; 2],
1490    map: &Map,
1491) -> Vec<LaidObject> {
1492    let mut out = Vec::new();
1493    for portion in crate::erosion::of_section(sec) {
1494        let (Some(older), Some(younger)) = (
1495            traces.iter().find(|t| t.item.object == portion.older),
1496            traces.iter().find(|t| t.item.object == portion.younger),
1497        ) else {
1498            continue;
1499        };
1500        let (lo, hi) = (portion.s0.max(plo), portion.s1.min(phi));
1501        if hi <= lo {
1502            continue;
1503        }
1504        let part = |t: &KeptTrace<'_>| t.item.spans.iter().find_map(|s| fill::clip_span(s, lo, hi));
1505        let (Some(upper), Some(lower)) = (part(older), part(younger)) else {
1506            continue;
1507        };
1508        let mut ring = upper.clone();
1509        ring.extend(lower.iter().rev().copied());
1510        out.push(LaidObject {
1511            attr: Attr::Decor("erosion".into()),
1512            kind: "erosion".into(),
1513            title: format!("{} / {}", portion.older.title, portion.younger.title),
1514            highlighted: false,
1515            at: None,
1516            shapes: vec![
1517                Shape::Polygon {
1518                    points: map.points(&ring),
1519                    fill: EROSION_COLOUR.into(),
1520                    opacity: FILL_OPACITY,
1521                    stroke: None,
1522                    dash: false,
1523                },
1524                Shape::Polyline {
1525                    points: map.points(&upper),
1526                    stroke: EROSION_COLOUR.into(),
1527                    width: 3.0,
1528                    dash: true,
1529                },
1530            ],
1531            intervals: Vec::new(),
1532        });
1533    }
1534    out
1535}
1536
1537/// Levels a mark's text may drop to, from the edge it hugs.
1538const MARK_LEVELS: usize = 3;
1539/// The distance between two levels: a text's height plus the air the
1540/// placer keeps between two labels, so a lower level is clear of the upper.
1541const MARK_LEVEL_PX: f64 = SMALL_PX + 2.0 + labels::GAP_PX;
1542
1543/// The short form of a mark's text: "× cross 3" is "×3", "× long" is "×L".
1544/// A vertex's "V3" is short already and comes back as it is.
1545fn short_mark_text(text: &str) -> String {
1546    match text.strip_prefix("× cross ") {
1547        Some(n) => format!("×{n}"),
1548        None if text == "× long" => "×L".to_string(),
1549        None => text.to_string(),
1550    }
1551}
1552
1553/// Pixels of line per chevron on a long segment.
1554const CHEVRON_EVERY_PX: f64 = 100.0;
1555/// A chevron keeps this far (along the line) from a vertex.
1556const CHEVRON_CLEAR_PX: f64 = 6.0;
1557/// A chevron's half length along the line, and half spread across it.
1558const CHEVRON_ALONG_PX: f64 = 3.0;
1559const CHEVRON_ACROSS_PX: f64 = 3.5;
1560
1561/// Chevrons (`>` laid on the line) and a dot at the start, for a section's line
1562/// in pixels: the way the abscissa grows from point 0. One per
1563/// [`CHEVRON_EVERY_PX`] of each segment and at least one per segment, so a bend
1564/// shows its new direction; each oriented along **its** segment, evenly
1565/// spaced inside it and clear of its vertices. A segment too short to keep
1566/// clear of both (under twice [`CHEVRON_CLEAR_PX`]) gets none.
1567fn direction_marks(px: &[[f64; 2]], colour: &str) -> Vec<Shape> {
1568    let mut out = Vec::new();
1569    let Some(first) = px.first() else { return out };
1570    for pair in px.windows(2) {
1571        let (a, b) = (pair[0], pair[1]);
1572        let len = (b[0] - a[0]).hypot(b[1] - a[1]);
1573        if len < 2.0 * CHEVRON_CLEAR_PX {
1574            continue;
1575        }
1576        let (ux, uy) = ((b[0] - a[0]) / len, (b[1] - a[1]) / len);
1577        let (nx, ny) = (-uy, ux);
1578        let n = ((len / CHEVRON_EVERY_PX).floor() as usize).max(1);
1579        for i in 0..n {
1580            let t = (i as f64 + 0.5) / n as f64 * len;
1581            let c = [a[0] + ux * t, a[1] + uy * t];
1582            let tip = [c[0] + ux * CHEVRON_ALONG_PX, c[1] + uy * CHEVRON_ALONG_PX];
1583            let back = [c[0] - ux * CHEVRON_ALONG_PX, c[1] - uy * CHEVRON_ALONG_PX];
1584            let left = [back[0] + nx * CHEVRON_ACROSS_PX, back[1] + ny * CHEVRON_ACROSS_PX];
1585            let right = [back[0] - nx * CHEVRON_ACROSS_PX, back[1] - ny * CHEVRON_ACROSS_PX];
1586            out.push(Shape::Polyline {
1587                points: vec![left, tip, right],
1588                stroke: colour.to_string(),
1589                width: 1.2,
1590                dash: false,
1591            });
1592        }
1593    }
1594    if !out.is_empty() {
1595        out.push(Shape::Circle {
1596            c: *first,
1597            r: 2.5,
1598            fill: colour.to_string(),
1599        });
1600    }
1601    out
1602}
1603
1604/// The title's font size and the air kept between the title band's texts.
1605const TITLE_PX: f64 = 14.0;
1606const TITLE_GAP_PX: f64 = 16.0;
1607
1608/// Where a drawing's first point is, for its title band: `origin (s = 0): X 500000.0 ·
1609/// Y 6000000.0 - EPSG:2154`. The band is shared with the title (left, bold) and
1610/// the exaggeration (right): the line goes at the centre of the free space
1611/// between them, in the form that fits - whole, then the EPSG code alone, then
1612/// nothing - and a shortened or dropped one is counted in `warnings`, like the
1613/// marks that find no room. Widths are the embedded font's, the title's with
1614/// the margin a bold face needs. The vertical system is not written: the
1615/// catalogue does not carry it.
1616fn origin_line(
1617    on: bool,
1618    origin: Option<[f64; 2]>,
1619    system: Option<&crate::model::System>,
1620    title: &str,
1621    right_text: &str,
1622    frame: &Rect,
1623    warnings: &mut Vec<String>,
1624) -> (String, f64) {
1625    let (true, Some([x, y])) = (on, origin) else {
1626        return (String::new(), 0.0);
1627    };
1628    if !x.is_finite() || !y.is_finite() {
1629        return (String::new(), 0.0);
1630    }
1631    let code = system.map(|s| format!("EPSG:{}", s.epsg));
1632    let whole = match &code {
1633        Some(c) => format!("origin (s = 0): X {x:.1} · Y {y:.1} - {c}"),
1634        None => format!("origin (s = 0): X {x:.1} · Y {y:.1}"),
1635    };
1636    let left = frame.x + labels::width_px(title, TITLE_PX) * 1.1 + TITLE_GAP_PX;
1637    let right = frame.x + frame.w - labels::width_px(right_text, FONT_PX) - TITLE_GAP_PX;
1638    let room = right - left;
1639    let put = |text: String| {
1640        let w = labels::width_px(&text, FONT_PX);
1641        let x = (frame.x + frame.w / 2.0 - w / 2.0).clamp(left, (right - w).max(left));
1642        (text, x)
1643    };
1644    if labels::width_px(&whole, FONT_PX) <= room {
1645        return put(whole);
1646    }
1647    if let Some(c) = code {
1648        let short = format!("origin: {c}");
1649        if labels::width_px(&short, FONT_PX) <= room {
1650            warnings.push(
1651                "origin position shortened to its EPSG code - the title band is too narrow".to_string(),
1652            );
1653            return put(short);
1654        }
1655    }
1656    warnings.push("origin position not written - the title band is too narrow".to_string());
1657    (String::new(), 0.0)
1658}
1659
1660/// The exaggeration as it is written: three significant figures.
1661fn round_sig(v: f64) -> String {
1662    if !v.is_finite() || v <= 0.0 {
1663        return "?".to_string();
1664    }
1665    let decimals = (2 - v.log10().floor() as i64).clamp(0, 6) as usize;
1666    let text = format!("{v:.decimals$}");
1667    if text.contains('.') {
1668        text.trim_end_matches('0').trim_end_matches('.').to_string()
1669    } else {
1670        text
1671    }
1672}
1673
1674fn x_axis(sec: &Section, map: &Map, mode: AxisMode, unit: &str) -> Axis {
1675    let (step, values) = ticks::values(map.window.x[0], map.window.x[1], map.frame.w / 90.0);
1676    let suffix = if unit.is_empty() {
1677        String::new()
1678    } else {
1679        format!(" ({unit})")
1680    };
1681    let ticks = values
1682        .into_iter()
1683        .map(|v| {
1684            let sublabel = (mode == AxisMode::Coordinates && sec.trace.len() >= 2).then(|| {
1685                let [x, y] = energyml_geometry::fence::point_at(&sec.trace, v);
1686                format!("{x:.0}, {y:.0}")
1687            });
1688            Tick {
1689                value: v,
1690                at: map.px([v, 0.0])[0],
1691                label: ticks::label(v, step),
1692                sublabel,
1693            }
1694        })
1695        .collect();
1696    Axis {
1697        title: match mode {
1698            AxisMode::Coordinates => format!("distance along the trace{suffix} - true coordinates (x, y)"),
1699            _ => format!("distance along the trace{suffix}"),
1700        },
1701        mode,
1702        ticks,
1703    }
1704}
1705
1706fn y_axis(map: &Map, unit: &str, name: &str) -> Axis {
1707    let (step, values) = ticks::values(map.window.y[0], map.window.y[1], map.frame.h / 40.0);
1708    let suffix = if unit.is_empty() {
1709        String::new()
1710    } else {
1711        format!(" ({unit})")
1712    };
1713    Axis {
1714        title: format!("{name}{suffix}"),
1715        mode: AxisMode::Projected,
1716        ticks: values
1717            .into_iter()
1718            .map(|v| Tick {
1719                value: v,
1720                at: map.px([0.0, v])[1],
1721                label: ticks::label(v, step),
1722                sublabel: None,
1723            })
1724            .collect(),
1725    }
1726}
1727
1728/// The colour of a well's whole line, per the view's choice.
1729fn well_colour(w: &WellItem, view: &View) -> String {
1730    let side = match w.side {
1731        Side::Left => LEFT_COLOUR,
1732        Side::Right => RIGHT_COLOUR,
1733    };
1734    match view.well_colouring {
1735        WellColouring::Side => side.to_string(),
1736        // The scene's colour - or, when the scene gives none, a neutral one
1737        // rather than a side colour the user did not ask for.
1738        WellColouring::Scene => {
1739            colour_of(view, &w.object.key()).unwrap_or_else(|| NEUTRAL_COLOUR.to_string())
1740        }
1741        // Under strata, the line itself is the well's scene colour or neutral:
1742        // an interval with no colour is drawn in it, never left transparent.
1743        WellColouring::Strata => {
1744            colour_of(view, &w.object.key()).unwrap_or_else(|| NEUTRAL_COLOUR.to_string())
1745        }
1746    }
1747}
1748
1749/// Where each point of a surface's cut comes from (`SurfaceCut::sources`),
1750/// parallel to its spans - what lets `paint::surfaces::trace_pieces` lay a
1751/// per-node colour along it. **The catalogue's `TraceItem` does not carry it
1752/// yet** (only `SurfaceCut` does, and `assemble` does not copy it): until the
1753/// contract gives the item a `sources` field this answers `None`, and every
1754/// surface is drawn in its flat colour. The one line to change is here.
1755fn cut_sources(item: &TraceItem) -> Option<&[Vec<CutSource>]> {
1756    (!item.sources.is_empty()).then_some(item.sources.as_slice())
1757}
1758
1759/// The sources of one kept span: the original span it still is, point for
1760/// point - a span a portion cut (or whose repeated points were dropped) is no
1761/// longer parallel to its sources and is not painted.
1762fn source_of_span<'a>(
1763    item: &TraceItem,
1764    sources: &'a [Vec<CutSource>],
1765    span: &[[f64; 2]],
1766) -> Option<&'a [CutSource]> {
1767    item.spans
1768        .iter()
1769        .zip(sources)
1770        .find(|(own, from)| own.as_slice() == span && from.len() == span.len())
1771        .map(|(_, from)| from.as_slice())
1772}
1773
1774/// The paint the scene gives a well: its own key's first, and - one line being
1775/// the frame and the trajectory of one wellbore, folded - then the keys
1776/// of the objects folded into it, in the order the catalogue lists them. The
1777/// first that has one wins; the line that is drawn is the kept object's, so a
1778/// paint given to the frame is the common case and the others are the fallback
1779/// (a front end that resolved the trajectory's colours, not the frame's).
1780fn paint_of<'a>(view: &'a View, w: &WellItem) -> Option<(&'a Paint, String)> {
1781    // **Only under scene colouring**, which is what it means: "the colour the
1782    // scene gives them". Under strata colouring the strata are what was asked,
1783    // and a well the scene happens to paint kept neither its units nor its ticks
1784    // (the black ticks at the stratigraphic changes would vanish and the colour would
1785    // look uniform along the boreholes).
1786    if view.well_colouring != WellColouring::Scene {
1787        return None;
1788    }
1789    std::iter::once(w.object.key())
1790        .chain(w.merged.iter().map(|o| o.key()))
1791        .find_map(|key| {
1792            view.paints
1793                .iter()
1794                .find(|(k, _)| *k == key)
1795                .map(|(_, paint)| (paint, key))
1796        })
1797}
1798
1799fn paint_refusal(w: &WellItem, why: &str) -> String {
1800    format!(
1801        "scene colours refused for {} - {why}; the well is drawn in its own colour",
1802        well_name(&w.object)
1803    )
1804}
1805
1806/// The MD at a pixel point of a well's laid path: the nearest segment's, the
1807/// two nodes' MDs interpolated.
1808fn md_at_px(path_px: &[[f64; 2]], md: &[f64], p: [f64; 2]) -> f64 {
1809    let mut best = (f64::INFINITY, md.first().copied().unwrap_or(0.0));
1810    for (i, pair) in path_px.windows(2).enumerate() {
1811        let (a, b) = (pair[0], pair[1]);
1812        let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
1813        let len2 = dx * dx + dy * dy;
1814        let t = if len2 > 0.0 {
1815            (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0)
1816        } else {
1817            0.0
1818        };
1819        let (ex, ey) = (a[0] + dx * t - p[0], a[1] + dy * t - p[1]);
1820        let d2 = ex * ex + ey * ey;
1821        if d2 < best.0 {
1822            best = (d2, md[i] + (md[i + 1] - md[i]) * t);
1823        }
1824    }
1825    best.1
1826}
1827
1828fn laid_well(
1829    w: &WellItem,
1830    view: &View,
1831    unstratified: &UnstratifiedStyle,
1832    map: &Map,
1833    unit: &str,
1834    warnings: &mut Vec<String>,
1835) -> LaidObject {
1836    let key = w.object.key();
1837    let colour = well_colour(w, view);
1838    let highlighted = view.highlight.contains(&key);
1839    let width = width_of(view, &key, view.well_width_px.max(1.0));
1840    let path = map.points(&w.path);
1841    let mut shapes = Vec::new();
1842    if highlighted {
1843        shapes.push(Shape::Polyline {
1844            points: path.clone(),
1845            stroke: "#ffb300".into(),
1846            width: width + 5.0,
1847            dash: false,
1848        });
1849    }
1850
1851    // What the scene paints along the well, under scene colouring, when it gives
1852    // something that fits: the pieces replace the single line - the scene's
1853    // paint is what the scene shows. A paint that does not fit was
1854    // said before the frame (`check`); the well is then drawn as below.
1855    let painted =
1856        paint_of(view, w).filter(|(paint, _)| crate::paint::check(w.path.len(), paint, &view.joints).is_ok());
1857    if let Some((paint, _)) = painted {
1858        // One pixel per node, none dropped and none merged: the paint is read per node.
1859        let path_px: Vec<[f64; 2]> = w.path.iter().map(|&p| map.px(p)).collect();
1860        match crate::paint::well_pieces(&path_px, paint, &view.joints) {
1861            Ok(pieces) => {
1862                shapes.push(Shape::Circle {
1863                    c: map.px(w.head),
1864                    r: (width / 2.0 + 1.5).max(2.5),
1865                    fill: colour.clone(),
1866                });
1867                let md = w.md.as_ref().filter(|md| md.len() == w.path.len());
1868                // The writer's interval has a butt cap: a gap between two
1869                // pieces stays a gap (a polyline's round cap would close it).
1870                let intervals = pieces
1871                    .into_iter()
1872                    .map(|piece| {
1873                        let (from, to) = match (md, piece.points.first(), piece.points.last()) {
1874                            (Some(md), Some(&a), Some(&b)) => {
1875                                (md_at_px(&path_px, md, a), md_at_px(&path_px, md, b))
1876                            }
1877                            _ => (0.0, 0.0),
1878                        };
1879                        let stroke = crate::paint::over(&piece.colour, piece.opacity, &view.background);
1880                        LaidInterval {
1881                            from,
1882                            to,
1883                            unit: String::new(),
1884                            unit_uri: None,
1885                            colour: stroke,
1886                            width,
1887                            points: piece.points,
1888                            tick: None,
1889                        }
1890                    })
1891                    .collect();
1892                return LaidObject {
1893                    attr: Attr::Key(key),
1894                    kind: "well".into(),
1895                    title: w.object.title.clone(),
1896                    highlighted,
1897                    at: Some(w.head),
1898                    shapes,
1899                    intervals,
1900                };
1901            }
1902            Err(why) => warnings.push(paint_refusal(w, &why)),
1903        }
1904    }
1905
1906    // The line itself is the well's colour (`well_colour`: the scene's, or the
1907    // neutral grey) and shows wherever no interval is drawn over it - the dark
1908    // portions of a well with no strata information. Not a "no information"
1909    // style, only what lies under the intervals. Off: only the intervals draw.
1910    if view.well_colouring != WellColouring::Strata || unstratified.is_default() {
1911        // The line under the intervals - what a drawing always was, byte for byte.
1912        shapes.push(Shape::Polyline {
1913            points: path,
1914            stroke: colour.clone(),
1915            width,
1916            dash: false,
1917        });
1918    } else {
1919        shapes.extend(unstratified_shapes(w, map, unstratified, width));
1920    }
1921    shapes.push(Shape::Circle {
1922        c: map.px(w.head),
1923        r: (width / 2.0 + 1.5).max(2.5),
1924        fill: colour.clone(),
1925    });
1926
1927    let mut intervals = Vec::new();
1928    let mut last_to: Option<f64> = None;
1929    if let Some(md) = w.md.as_ref().filter(|md| md.len() == w.path.len()) {
1930        for iv in &w.intervals {
1931            let pts = sub_path(&w.path, md, iv.from, iv.to);
1932            let points = map.points(&pts);
1933            if points.len() < 2 {
1934                continue;
1935            }
1936            // Without a colour of its own, the interval takes the well's.
1937            let drawn = interval_colour(iv, &w.object.store, &colour, view);
1938            let tick = if view.strata_ticks && view.well_colouring == WellColouring::Strata {
1939                let adjacent = last_to.is_some_and(|before| (before - iv.from).abs() < 1e-9);
1940                if adjacent {
1941                    tick_across(&points, width)
1942                } else {
1943                    None
1944                }
1945            } else {
1946                None
1947            };
1948            last_to = Some(iv.to);
1949            intervals.push(LaidInterval {
1950                from: iv.from,
1951                to: iv.to,
1952                unit: iv.unit.clone(),
1953                unit_uri: iv.unit_uri.clone(),
1954                colour: drawn,
1955                width,
1956                points,
1957                tick,
1958            });
1959        }
1960    }
1961    let _ = unit;
1962    LaidObject {
1963        attr: Attr::Key(key),
1964        kind: "well".into(),
1965        title: w.object.title.clone(),
1966        highlighted,
1967        at: Some(w.head),
1968        shapes,
1969        intervals,
1970    }
1971}
1972
1973/// The style the unstratified portions are drawn in: the view's, or the default
1974/// with a note naming why when it cannot be drawn (`UnstratifiedStyle::refusal`).
1975fn effective_style(view: &View, warnings: &mut Vec<String>) -> UnstratifiedStyle {
1976    let style = view.unstratified_style();
1977    match style.refusal() {
1978        None => style,
1979        Some(why) => {
1980            warnings.push(format!(
1981                "unstratified style not drawn: {why}; the default is drawn"
1982            ));
1983            UnstratifiedStyle::default()
1984        }
1985    }
1986}
1987
1988/// The id of the `<pattern>` the hatched style strokes with - prefixed, so that
1989/// it cannot clash with an id of a drawing this one is pasted into.
1990pub const HATCH_ID: &str = "esv-unstratified-hatch";
1991
1992/// What the stroke or fill of the hatched style is: the pattern's reference.
1993pub fn hatch_ref() -> String {
1994    format!("url(#{HATCH_ID})")
1995}
1996
1997/// The portions of a well's path (as MD intervals) that **no interval covers**,
1998/// between its first and last MD. `None`: the well has no MD to lay intervals
1999/// on, so the whole path is one portion. **The one reading** of "unstratified":
2000/// the drawing, the note counting lost wells and the legend all ask it.
2001pub fn unstratified_spans(w: &WellItem) -> Option<Vec<(f64, f64)>> {
2002    let md =
2003        w.md.as_ref()
2004            .filter(|md| md.len() == w.path.len() && md.len() > 1)?;
2005    let (lo, hi) = (md[0], md[md.len() - 1]);
2006    let mut covered: Vec<(f64, f64)> = w
2007        .intervals
2008        .iter()
2009        .map(|iv| (iv.from.max(lo), iv.to.min(hi)))
2010        .filter(|(a, b)| b > a)
2011        .collect();
2012    covered.sort_by(|a, b| a.0.total_cmp(&b.0));
2013    let mut out = Vec::new();
2014    let mut reached = lo;
2015    for (a, b) in covered {
2016        if a > reached + 1e-9 {
2017            out.push((reached, a));
2018        }
2019        reached = reached.max(b);
2020    }
2021    if reached < hi - 1e-9 {
2022        out.push((reached, hi));
2023    }
2024    Some(out)
2025}
2026
2027/// The unstratified portions of a well, drawn in `style` (not the default, which
2028/// is the well's line under its intervals): one shape per portion, laid along
2029/// the path between its two MDs.
2030fn unstratified_shapes(w: &WellItem, map: &Map, style: &UnstratifiedStyle, width: f64) -> Vec<Shape> {
2031    let pieces: Vec<Vec<[f64; 2]>> = match (unstratified_spans(w), w.md.as_ref()) {
2032        (Some(spans), Some(md)) => spans
2033            .into_iter()
2034            .map(|(a, b)| map.points(&sub_path(&w.path, md, a, b)))
2035            .collect(),
2036        _ => vec![map.points(&w.path)],
2037    };
2038    pieces
2039        .into_iter()
2040        .filter(|points| points.len() >= 2)
2041        .filter_map(|points| match style {
2042            UnstratifiedStyle::Hidden => None,
2043            UnstratifiedStyle::Solid { colour } => Some(Shape::Polyline {
2044                points,
2045                stroke: colour.clone(),
2046                width,
2047                dash: false,
2048            }),
2049            UnstratifiedStyle::Dashed { colour, dash, gap } => Some(Shape::Dashed {
2050                points,
2051                stroke: colour.clone(),
2052                width,
2053                dash: *dash,
2054                gap: *gap,
2055            }),
2056            UnstratifiedStyle::Hatched { .. } => Some(Shape::Polyline {
2057                points,
2058                stroke: hatch_ref(),
2059                width,
2060                dash: false,
2061            }),
2062        })
2063        .collect()
2064}
2065
2066/// How far a boundary tick reaches beyond the well's own edge, pixels.
2067const TICK_OVERHANG_PX: f64 = 2.0;
2068
2069/// The tick across a well at the start of `points` (an interval's piece of path):
2070/// perpendicular to the path there, centred on it, as wide as the well and a
2071/// little more. `None` when the piece has no direction.
2072fn tick_across(points: &[[f64; 2]], width: f64) -> Option<[[f64; 2]; 2]> {
2073    let (a, b) = (points.first()?, points.get(1)?);
2074    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
2075    let length = dx.hypot(dy);
2076    // A `NaN` length is no direction either: `is_finite` says so, `> 0.0` the rest.
2077    if !length.is_finite() || length <= 0.0 {
2078        return None;
2079    }
2080    let half = width / 2.0 + TICK_OVERHANG_PX;
2081    let (nx, ny) = (-dy / length * half, dx / length * half);
2082    Some([[a[0] - nx, a[1] - ny], [a[0] + nx, a[1] + ny]])
2083}
2084
2085/// The colour a unit is given **now** - [`View::unit_colours`], by the key of the
2086/// well's package and the unit's full uri - else the one baked into the interval
2087/// when the catalogue was made. **The one reading** both the wells and the strata
2088/// legend make of "the colour of an interval", so they cannot disagree.
2089fn own_colour(iv: &Interval, store: &str, view: &View) -> Option<String> {
2090    let told = iv.unit_uri.as_deref().and_then(|uri| {
2091        let key = crate::model::object_key(store, uri);
2092        view.unit_colours
2093            .iter()
2094            .find(|(k, _)| *k == key)
2095            .map(|(_, colour)| colour.clone())
2096    });
2097    told.or_else(|| iv.colour.clone())
2098}
2099
2100/// The colour an interval is drawn in: its own under strata colouring, the
2101/// well's when the file gives it none or the wells are coloured another way.
2102/// **The one place** it is decided - the strata legend asks the same.
2103fn interval_colour(iv: &Interval, store: &str, well_colour: &str, view: &View) -> String {
2104    match view.well_colouring {
2105        WellColouring::Strata => own_colour(iv, store, view).unwrap_or_else(|| well_colour.to_string()),
2106        _ => well_colour.to_string(),
2107    }
2108}
2109
2110/// The piece of a path between two MDs, the ends interpolated.
2111fn sub_path(path: &[[f64; 2]], md: &[f64], from: f64, to: f64) -> Vec<[f64; 2]> {
2112    let mut out: Vec<[f64; 2]> = Vec::new();
2113    for i in 0..path.len().saturating_sub(1) {
2114        let (m0, m1) = (md[i], md[i + 1]);
2115        let (lo, hi) = (from.max(m0), to.min(m1));
2116        if hi < lo || m1 <= m0 {
2117            continue;
2118        }
2119        let at = |m: f64| {
2120            let t = (m - m0) / (m1 - m0);
2121            [
2122                path[i][0] + (path[i + 1][0] - path[i][0]) * t,
2123                path[i][1] + (path[i + 1][1] - path[i][1]) * t,
2124            ]
2125        };
2126        let a = at(lo);
2127        if out.last() != Some(&a) {
2128            out.push(a);
2129        }
2130        out.push(at(hi));
2131    }
2132    out
2133}
2134
2135/// A well's name: its title, or the head of its uuid when the title is empty.
2136fn well_name(object: &crate::model::ObjectId) -> String {
2137    if object.title.is_empty() {
2138        object.uuid.chars().take(8).collect()
2139    } else {
2140        object.title.clone()
2141    }
2142}
2143
2144fn side_letter(side: Side) -> &'static str {
2145    match side {
2146        Side::Left => "L",
2147        Side::Right => "R",
2148    }
2149}
2150
2151/// The wells' labels, placed without overlap ([`head_labels`]); the second value
2152/// is how many were folded. In codes mode a well is named by its code.
2153fn well_labels(
2154    wells: &[&WellItem],
2155    view: &View,
2156    map: &Map,
2157    unit: &str,
2158    coded: Option<&HashMap<String, String>>,
2159    obstacles: &[labels::Rect2],
2160) -> (Vec<LaidObject>, usize) {
2161    if !(view.show.names || view.show.distances) {
2162        return (Vec::new(), 0);
2163    }
2164    let mut cands = Vec::new();
2165    for w in wells {
2166        let head = map.px(w.head);
2167        if head[0] < map.frame.x || head[0] > map.frame.x + map.frame.w {
2168            continue;
2169        }
2170        let mut lines = Vec::new();
2171        if view.show.names {
2172            lines.push(labels::fit(
2173                &shown_name(coded, &w.object),
2174                FONT_PX,
2175                labels::MAX_WIDTH_PX,
2176            ));
2177        }
2178        if view.show.distances {
2179            let space = if unit.is_empty() { "" } else { " " };
2180            lines.push(format!("{:.0}{space}{unit} {}", w.d, side_letter(w.side)));
2181        }
2182        // A head near the top of the plot is labelled above the plot - the
2183        // labels do not cover the data, the marks or each other's wells.
2184        let floor = if head[1] < map.frame.y + map.frame.h * 0.2 {
2185            map.frame.y - 2.0
2186        } else {
2187            head[1] - 4.0
2188        };
2189        cands.push(HeadLabel {
2190            key: w.object.key(),
2191            title: w.object.title.clone(),
2192            head,
2193            lines,
2194            floor,
2195        });
2196    }
2197    // Anywhere below the drawing's title and left of the legend.
2198    let bounds = labels::Rect2 {
2199        x0: 2.0,
2200        y0: TITLE_BAND_PX,
2201        x1: map.frame.x + map.frame.w,
2202        y1: map.frame.y + map.frame.h,
2203    };
2204    head_labels(cands, bounds, obstacles)
2205}
2206
2207// ---------------------------------------------------------------------------
2208// The plan
2209
2210fn plan(catalogue: &Catalogue, view: &View) -> Layout {
2211    let p: &Plan = &catalogue.plan;
2212    let mut warnings = common_warnings(catalogue);
2213    warnings.extend(width_warnings(view));
2214    let unit = unit_of(catalogue);
2215    if view.axis != AxisMode::Projected {
2216        warnings.push("the plan is drawn in local plan coordinates whatever the axis mode".to_string());
2217    }
2218    let wells: Vec<_> = p
2219        .wells
2220        .iter()
2221        .filter(|w| !is_hidden(view, &w.object.key()))
2222        .collect();
2223
2224    let mut e = Extent::default();
2225    for (_, t) in &p.sections {
2226        e.add_all(t);
2227    }
2228    if view.show.bands {
2229        for (_, b) in &p.bands {
2230            e.add_all(b);
2231        }
2232    }
2233    for w in &wells {
2234        if w.rejected.is_none() || view.show.rejected {
2235            e.add(w.head);
2236        }
2237        for (_, f) in &w.feet {
2238            e.add(*f);
2239        }
2240    }
2241    let given = view.window;
2242    let everything = Window {
2243        x: padded(e.x, 0.05),
2244        y: padded(e.y, 0.05),
2245    };
2246    let wanted = given.unwrap_or(everything);
2247    if view.strata_legend {
2248        warnings.push("strata legend not drawn: the plan shows no strata along its wells".to_string());
2249    }
2250    if !view.markers.is_empty() {
2251        warnings.push(format!(
2252            "{} marker(s) not drawn: markers are drawn in sections only",
2253            view.markers.len()
2254        ));
2255    }
2256    let labelled = view.show.names;
2257    let reserved = if view.show.notes {
2258        warnings.len() + usize::from(labelled && !wells.is_empty())
2259    } else {
2260        0
2261    };
2262    // Codes mode: the same codes as on every section, and their legend.
2263    let coded = (view.labels == LabelMode::Codes).then(|| codes(catalogue));
2264    let mut entries: Vec<LegendEntry> = Vec::new();
2265    if let Some(coded) = &coded {
2266        for w in &wells {
2267            if w.rejected.is_some() && !view.show.rejected {
2268                continue;
2269            }
2270            if let Some(code) = coded.get(&w.object.key()) {
2271                entries.push(LegendEntry {
2272                    code: code.clone(),
2273                    title: well_name(&w.object),
2274                    colour: NEUTRAL_COLOUR.into(),
2275                    round: true,
2276                });
2277            }
2278        }
2279    }
2280    let legend_w = legend_width(&entries);
2281    let (_, _, note_lines) = budget(view.size_px[1], 1, reserved, |_| PLAN_TOP);
2282    let frame = frame_of(
2283        view,
2284        note_lines,
2285        if legend_w > 0.0 { legend_w + 12.0 } else { 0.0 },
2286        0.0,
2287        PLAN_TOP,
2288    );
2289    // Equal scales: the window is widened, never one axis stretched.
2290    let equal = |w: Window| {
2291        let scale = (frame.w / (w.x[1] - w.x[0])).min(frame.h / (w.y[1] - w.y[0]));
2292        let (cx, cy) = ((w.x[0] + w.x[1]) / 2.0, (w.y[0] + w.y[1]) / 2.0);
2293        (
2294            scale,
2295            Window {
2296                x: [cx - frame.w / scale / 2.0, cx + frame.w / scale / 2.0],
2297                y: [cy - frame.h / scale / 2.0, cy + frame.h / scale / 2.0],
2298            },
2299        )
2300    };
2301    let (scale, window) = equal(wanted);
2302    let whole = if given.is_some() {
2303        equal(everything).1
2304    } else {
2305        window
2306    };
2307    let map = Map {
2308        window,
2309        frame,
2310        sx: scale,
2311        sy: scale,
2312    };
2313
2314    let mut layers = Vec::new();
2315    let mut section_objects = Vec::new();
2316    if view.show.bands {
2317        for (k, band) in &p.bands {
2318            section_objects.push(LaidObject {
2319                attr: Attr::Section(key_text(*k)),
2320                kind: "band".into(),
2321                title: String::new(),
2322                highlighted: false,
2323                at: None,
2324                shapes: vec![Shape::Polygon {
2325                    points: map.points(band),
2326                    fill: "#90a4ae".into(),
2327                    opacity: 0.15,
2328                    stroke: Some("#90a4ae".into()),
2329                    dash: true,
2330                }],
2331                intervals: Vec::new(),
2332            });
2333        }
2334    }
2335    layers.push(Layer {
2336        name: "fills",
2337        clipped: true,
2338        objects: section_objects,
2339    });
2340
2341    let mut lines = Vec::new();
2342    let mut directions = Vec::new();
2343    for (k, t) in &p.sections {
2344        if view.show.direction {
2345            let colour = if *k == SectionKey::Long { INK } else { MARK_COLOUR };
2346            let shapes = direction_marks(&map.points(t), colour);
2347            if !shapes.is_empty() {
2348                directions.push(LaidObject {
2349                    attr: Attr::Decor(format!("direction-{}", key_text(*k))),
2350                    kind: "direction".into(),
2351                    title: String::new(),
2352                    highlighted: false,
2353                    at: None,
2354                    shapes,
2355                    intervals: Vec::new(),
2356                });
2357            }
2358        }
2359        lines.push(LaidObject {
2360            attr: Attr::Section(key_text(*k)),
2361            kind: "section".into(),
2362            title: String::new(),
2363            highlighted: false,
2364            at: None,
2365            shapes: vec![Shape::Polyline {
2366                points: map.points(t),
2367                stroke: if *k == SectionKey::Long { INK } else { MARK_COLOUR }.into(),
2368                width: if *k == SectionKey::Long { 2.0 } else { 1.2 },
2369                dash: false,
2370            }],
2371            intervals: Vec::new(),
2372        });
2373    }
2374    lines.extend(directions);
2375    layers.push(Layer {
2376        name: "traces",
2377        clipped: true,
2378        objects: lines,
2379    });
2380
2381    let mut well_objects = Vec::new();
2382    for w in &wells {
2383        let key = w.object.key();
2384        let out = w.rejected.is_some();
2385        if out && !view.show.rejected {
2386            continue;
2387        }
2388        let colour = if out {
2389            "#9e9e9e".to_string()
2390        } else {
2391            colour_of(view, &key).unwrap_or_else(|| NEUTRAL_COLOUR.into())
2392        };
2393        let head = map.px(w.head);
2394        let mut shapes = Vec::new();
2395        for (_, foot) in &w.feet {
2396            shapes.push(Shape::Polyline {
2397                points: vec![head, map.px(*foot)],
2398                stroke: colour.clone(),
2399                width: width_of(view, &key, 1.0),
2400                dash: true,
2401            });
2402        }
2403        shapes.push(Shape::Circle {
2404            c: head,
2405            r: 3.5,
2406            fill: colour,
2407        });
2408        well_objects.push(LaidObject {
2409            highlighted: view.highlight.contains(&key),
2410            attr: Attr::Key(key),
2411            kind: if out { "well-rejected" } else { "well" }.into(),
2412            title: w.object.title.clone(),
2413            at: Some(w.head),
2414            shapes,
2415            intervals: Vec::new(),
2416        });
2417    }
2418    layers.push(Layer {
2419        name: "wells",
2420        clipped: true,
2421        objects: well_objects,
2422    });
2423    layers.push(Layer {
2424        name: "marks",
2425        clipped: true,
2426        objects: Vec::new(),
2427    });
2428
2429    // Labels: names only, above the head, by the same placer as in a section.
2430    let mut label_objects = Vec::new();
2431    let mut folded = 0;
2432    if view.show.names {
2433        let mut cands = Vec::new();
2434        for w in &wells {
2435            if w.rejected.is_some() && !view.show.rejected {
2436                continue;
2437            }
2438            let head = map.px(w.head);
2439            if head[0] < frame.x
2440                || head[0] > frame.x + frame.w
2441                || head[1] < frame.y
2442                || head[1] > frame.y + frame.h
2443            {
2444                continue;
2445            }
2446            cands.push(HeadLabel {
2447                key: w.object.key(),
2448                title: w.object.title.clone(),
2449                head,
2450                lines: vec![labels::fit(
2451                    &shown_name(coded.as_ref(), &w.object),
2452                    FONT_PX,
2453                    labels::MAX_WIDTH_PX,
2454                )],
2455                floor: head[1] - 4.0,
2456            });
2457        }
2458        // A label never covers a head: a head is what the plan is read for.
2459        let heads: Vec<labels::Rect2> = cands
2460            .iter()
2461            .map(|c| labels::Rect2 {
2462                x0: c.head[0] - 4.0,
2463                y0: c.head[1] - 4.0,
2464                x1: c.head[0] + 4.0,
2465                y1: c.head[1] + 4.0,
2466            })
2467            .collect();
2468        let bounds = labels::Rect2 {
2469            x0: frame.x,
2470            y0: frame.y,
2471            x1: frame.x + frame.w,
2472            y1: frame.y + frame.h,
2473        };
2474        (label_objects, folded) = head_labels(cands, bounds, &heads);
2475    }
2476    layers.push(Layer {
2477        name: "labels",
2478        clipped: false,
2479        objects: label_objects,
2480    });
2481    let legend_box = legend(
2482        entries,
2483        frame.x + frame.w + 12.0,
2484        legend_w,
2485        frame.y,
2486        frame.y + frame.h,
2487    );
2488    layers.push(Layer {
2489        name: "legend",
2490        clipped: false,
2491        objects: legend_box.into_iter().collect(),
2492    });
2493    if folded > 0 {
2494        warnings.push(format!("{folded} well labels folded - they did not fit"));
2495    }
2496
2497    let (xs, xv) = ticks::values(window.x[0], window.x[1], frame.w / 90.0);
2498    let x_axis = Axis {
2499        title: format!(
2500            "x, local{}",
2501            if unit.is_empty() {
2502                String::new()
2503            } else {
2504                format!(" ({unit})")
2505            }
2506        ),
2507        mode: AxisMode::Projected,
2508        ticks: xv
2509            .into_iter()
2510            .map(|v| Tick {
2511                value: v,
2512                at: map.px([v, 0.0])[0],
2513                label: ticks::label(v, xs),
2514                sublabel: None,
2515            })
2516            .collect(),
2517    };
2518    let y_axis = y_axis(&map, &unit, "y, local");
2519
2520    let (origin_text, origin_x) = origin_line(
2521        view.show.origin,
2522        Some(catalogue.plan.origin),
2523        catalogue
2524            .inputs
2525            .system
2526            .as_ref()
2527            .or(catalogue.plan.system.as_ref()),
2528        "Plan",
2529        "scale 1:1 on both axes",
2530        &frame,
2531        &mut warnings,
2532    );
2533
2534    Layout {
2535        target: view.target,
2536        title: "Plan".to_string(),
2537        size: view.size_px,
2538        background: view.background.clone(),
2539        frame,
2540        window,
2541        whole,
2542        scale: [scale, scale],
2543        exaggeration: None,
2544        exaggeration_text: "scale 1:1 on both axes".to_string(),
2545        origin_text,
2546        origin_x,
2547        x_axis,
2548        y_axis,
2549        layers,
2550        warnings,
2551        folded_labels: folded,
2552        note_lines,
2553        metadata: metadata(catalogue, view),
2554        left_shift: 0.0,
2555    }
2556}
2557
2558// ---------------------------------------------------------------------------
2559// Names: placed without overlap, or shortened to codes with a legend
2560
2561/// How many levels above its head a well's label may climb before it is folded.
2562const HEAD_LEVELS: usize = 5;
2563/// The levels of well labels the band above a section's plot is made for.
2564const HEAD_BAND_LEVELS: usize = 3;
2565/// The drawing's title band, which no label enters.
2566const TITLE_BAND_PX: f64 = 22.0;
2567/// How many steps a surface's name may slide left along its line.
2568const NAME_SHIFTS: usize = 6;
2569/// The legend's width bounds, pixels.
2570const LEGEND_MIN_PX: f64 = 120.0;
2571const LEGEND_MAX_PX: f64 = 280.0;
2572
2573/// The code of every object the catalogue draws - `W` wells, `H` horizons, `F`
2574/// faults, `S` other surfaces, `G` grids - numbered in **one order for the whole
2575/// catalogue**, so an object keeps its code on every section and on the plan:
2576/// wells by their abscissa on the long section, then by cross section; horizons
2577/// and other surfaces top first (their mean height where first cut); faults
2578/// along the trace.
2579fn codes(catalogue: &Catalogue) -> HashMap<String, String> {
2580    let sections: Vec<&Section> = std::iter::once(&catalogue.long)
2581        .chain(&catalogue.crosses)
2582        .collect();
2583    let mut wells: Vec<(String, usize, f64)> = Vec::new();
2584    // (key, family, sort value) - the first cut met decides.
2585    let mut surfaces: Vec<(String, char, f64)> = Vec::new();
2586    let mut seen = std::collections::HashSet::new();
2587    for (rank, sec) in sections.iter().enumerate() {
2588        for item in &sec.items {
2589            match item {
2590                Item::Well(w) => {
2591                    if seen.insert(w.object.key()) {
2592                        wells.push((w.object.key(), rank, w.s));
2593                    }
2594                }
2595                Item::Trace(t) => {
2596                    if seen.insert(t.object.key()) {
2597                        let (family, value) = match t.role {
2598                            TraceRole::Fault => ('F', mean_of(&t.spans, 0)),
2599                            TraceRole::Horizon => ('H', -mean_of(&t.spans, 1)),
2600                            TraceRole::Other => ('S', -mean_of(&t.spans, 1)),
2601                        };
2602                        surfaces.push((t.object.key(), family, rank as f64 * 1e12 + value));
2603                    }
2604                }
2605                Item::Envelope(e) => {
2606                    if seen.insert(e.object.key()) {
2607                        surfaces.push((e.object.key(), 'G', rank as f64 * 1e12 - mean_of(&e.top, 1)));
2608                    }
2609                }
2610            }
2611        }
2612    }
2613    for w in &catalogue.plan.wells {
2614        let key = w.object.key();
2615        if seen.insert(key.clone()) {
2616            wells.push((key, usize::MAX, 0.0));
2617        }
2618    }
2619    wells.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.total_cmp(&b.2)));
2620    let mut out: HashMap<String, String> = HashMap::new();
2621    for (n, (key, _, _)) in wells.into_iter().enumerate() {
2622        out.insert(key, format!("W{n}"));
2623    }
2624    for family in ['H', 'F', 'S', 'G'] {
2625        let mut these: Vec<&(String, char, f64)> = surfaces.iter().filter(|s| s.1 == family).collect();
2626        these.sort_by(|a, b| a.2.total_cmp(&b.2));
2627        for (n, (key, _, _)) in these.into_iter().enumerate() {
2628            out.insert(key.clone(), format!("{family}{n}"));
2629        }
2630    }
2631    out
2632}
2633
2634/// The mean of one coordinate over spans (0: s, 1: z); 0 when there is none.
2635fn mean_of(spans: &[Vec<[f64; 2]>], axis: usize) -> f64 {
2636    let (sum, n) = spans
2637        .iter()
2638        .flatten()
2639        .fold((0.0, 0usize), |(sum, n), p| (sum + p[axis], n + 1));
2640    if n == 0 { 0.0 } else { sum / n as f64 }
2641}
2642
2643/// What an object is called on the drawing: its code in codes mode, else its
2644/// title (or the head of its uuid).
2645fn shown_name(codes: Option<&HashMap<String, String>>, object: &crate::model::ObjectId) -> String {
2646    codes
2647        .and_then(|c| c.get(&object.key()).cloned())
2648        .unwrap_or_else(|| well_name(object))
2649}
2650
2651/// A label that hangs above a head: a well's name and distance.
2652struct HeadLabel {
2653    key: String,
2654    title: String,
2655    head: [f64; 2],
2656    lines: Vec<String>,
2657    /// The lowest a label's box may reach, pixels.
2658    floor: f64,
2659}
2660
2661/// Labels above heads, without overlap (on overlap a label goes
2662/// higher, possibly shifted to a side with no label nearby, and a dotted leader
2663/// joins it to the head when the name is far away). Each label tries, level by level going up, its own place centred
2664/// on the head, then a whole width to the right, then to the left; a label not
2665/// centred straight above its head gets a **dotted leader** to it. A label that
2666/// finds no place within [`HEAD_LEVELS`] levels is folded and counted.
2667fn head_labels(
2668    mut cands: Vec<HeadLabel>,
2669    bounds: labels::Rect2,
2670    obstacles: &[labels::Rect2],
2671) -> (Vec<LaidObject>, usize) {
2672    cands.sort_by(|a, b| a.head[0].total_cmp(&b.head[0]));
2673    let mut placer = labels::Placer::new(bounds);
2674    obstacles.iter().for_each(|r| placer.block(*r));
2675    let mut out = Vec::new();
2676    let mut folded = 0;
2677    for c in cands {
2678        let n = c.lines.len().max(1) as f64;
2679        let w = c
2680            .lines
2681            .iter()
2682            .map(|l| labels::width_px(l, FONT_PX))
2683            .fold(0.0, f64::max);
2684        let h = (n - 1.0) * LINE_PX + FONT_PX + 3.0;
2685        let mut spots: Vec<(labels::Rect2, f64, f64)> = Vec::new(); // rect, centre x, last baseline
2686        // Up first, straight above the head; then a little aside (the right
2687        // first: the labels are taken left to right, so the right is where
2688        // nothing is yet); a whole width aside last, which clears a neighbour
2689        // centred on almost the same head.
2690        for dx in [0.0, w / 2.0 + 8.0, -(w / 2.0 + 8.0), w + 8.0, -(w + 8.0)] {
2691            for level in 0..HEAD_LEVELS {
2692                let baseline = c.head[1] - 7.0 - level as f64 * (h + 3.0);
2693                let cx = c.head[0] + dx;
2694                let rect = labels::Rect2 {
2695                    x0: cx - w / 2.0,
2696                    y0: baseline + 3.0 - h,
2697                    x1: cx + w / 2.0,
2698                    y1: baseline + 3.0,
2699                };
2700                // A label never goes down into the plot past its own head's
2701                // band: above the plot when the head is near its top.
2702                if rect.y1 <= c.floor {
2703                    spots.push((rect, cx, baseline));
2704                }
2705            }
2706        }
2707        let rects: Vec<labels::Rect2> = spots.iter().map(|s| s.0).collect();
2708        let Some(at) = placer.place(&rects) else {
2709            folded += 1;
2710            continue;
2711        };
2712        let (rect, cx, baseline) = spots[at];
2713        let mut shapes = Vec::new();
2714        if at > 0 {
2715            // Not straight above its head: a dotted line says whose label it is.
2716            shapes.push(Shape::Polyline {
2717                points: vec![[cx, rect.y1], [c.head[0], c.head[1] - 4.0]],
2718                stroke: "#888888".into(),
2719                width: 0.7,
2720                dash: true,
2721            });
2722        }
2723        let lines = c.lines.len();
2724        for (j, line) in c.lines.iter().enumerate() {
2725            shapes.push(Shape::Text {
2726                at: [cx, baseline - (lines - 1 - j) as f64 * LINE_PX],
2727                text: line.clone(),
2728                anchor: Anchor::Middle,
2729                size: FONT_PX,
2730                fill: INK.into(),
2731            });
2732        }
2733        out.push(LaidObject {
2734            attr: Attr::LabelOf(c.key),
2735            kind: "label".into(),
2736            title: c.title,
2737            highlighted: false,
2738            at: None,
2739            shapes,
2740            intervals: Vec::new(),
2741        });
2742    }
2743    (out, folded)
2744}
2745
2746/// The boxes of the texts among `shapes`, measured with the sheet's table - what
2747/// a placer must keep clear of.
2748fn text_rects<'a>(shapes: impl Iterator<Item = &'a Shape>) -> Vec<labels::Rect2> {
2749    shapes
2750        .filter_map(|shape| match shape {
2751            Shape::Text {
2752                at,
2753                text,
2754                anchor,
2755                size,
2756                ..
2757            } => {
2758                let w = labels::width_px(text, *size);
2759                let x0 = match anchor {
2760                    Anchor::Start => at[0],
2761                    Anchor::Middle => at[0] - w / 2.0,
2762                    Anchor::End => at[0] - w,
2763                };
2764                Some(labels::Rect2 {
2765                    x0,
2766                    y0: at[1] - size,
2767                    x1: x0 + w,
2768                    y1: at[1] + 2.0,
2769                })
2770            }
2771            _ => None,
2772        })
2773        .collect()
2774}
2775
2776/// A surface's name to place at the end of its line.
2777struct LineName {
2778    /// Which trace object (index into the traces' layer) it belongs to.
2779    owner: usize,
2780    end: [f64; 2],
2781    text: String,
2782    colour: String,
2783}
2784
2785/// Surface names without overlap: each name first just above
2786/// the end of its line, then just below, then slid left along the line by its
2787/// own width - above, below - step by step; the names are taken top first, so a
2788/// name far from the others finds its first place free (the *reset*). A name
2789/// with no place within [`NAME_SHIFTS`] steps is folded and counted.
2790fn line_names(
2791    mut names: Vec<LineName>,
2792    bounds: labels::Rect2,
2793    obstacles: &[labels::Rect2],
2794) -> (Vec<(usize, Shape)>, usize) {
2795    names.sort_by(|a, b| a.end[1].total_cmp(&b.end[1]));
2796    let mut placer = labels::Placer::new(bounds);
2797    obstacles.iter().for_each(|r| placer.block(*r));
2798    let mut out = Vec::new();
2799    let mut folded = 0;
2800    for n in names {
2801        let w = labels::width_px(&n.text, SMALL_PX);
2802        let mut spots: Vec<(labels::Rect2, [f64; 2])> = Vec::new(); // rect, text anchor (end, baseline)
2803        for k in 0..NAME_SHIFTS {
2804            let right = n.end[0] - 3.0 - k as f64 * (w + 10.0);
2805            let above = n.end[1] - 3.0;
2806            let below = n.end[1] + SMALL_PX + 2.0;
2807            for baseline in [above, below] {
2808                let rect = labels::Rect2 {
2809                    x0: right - w,
2810                    y0: baseline - SMALL_PX,
2811                    x1: right,
2812                    y1: baseline + 2.0,
2813                };
2814                spots.push((rect, [right, baseline]));
2815            }
2816        }
2817        let rects: Vec<labels::Rect2> = spots.iter().map(|s| s.0).collect();
2818        match placer.place(&rects) {
2819            Some(at) => out.push((
2820                n.owner,
2821                Shape::Text {
2822                    at: spots[at].1,
2823                    text: n.text,
2824                    anchor: Anchor::End,
2825                    size: SMALL_PX,
2826                    fill: n.colour,
2827                },
2828            )),
2829            None => folded += 1,
2830        }
2831    }
2832    (out, folded)
2833}
2834
2835/// The layer the strata legend is written in (`svg.rs` gives it a group of its own).
2836pub(crate) const STRATA_LAYER: &str = "strata-legend";
2837/// The layer the legend of markers is written in (an Inkscape layer of its own, like the strata's).
2838pub(crate) const MARKERS_LAYER: &str = "markers-legend";
2839/// Where the strata legend starts from the drawing's left edge, pixels.
2840const STRATA_LEFT_PX: f64 = 4.0;
2841/// The space between the strata legend and the axes' own margin, pixels.
2842const STRATA_GAP_PX: f64 = 8.0;
2843
2844/// One unit the wells show, with the colour its interval is drawn in - and the
2845/// packages whose wells show it so. A unit is one entry per colour, not per
2846/// package: two packages that agree are one line, two that do not are two.
2847#[derive(Clone, Debug, PartialEq, Eq)]
2848struct StrataEntry {
2849    unit: String,
2850    colour: String,
2851    /// The unit's full uri, when its intervals name one.
2852    unit_uri: Option<String>,
2853    /// The front end's store ids, in the order their wells were met.
2854    stores: Vec<String>,
2855}
2856
2857impl StrataEntry {
2858    /// The same unit in the same colour - what merges two wells' intervals into
2859    /// one entry, whatever package each well is in.
2860    fn same_line(&self, unit: &str, colour: &str, unit_uri: Option<&str>) -> bool {
2861        self.unit == unit && self.colour == colour && self.unit_uri.as_deref() == unit_uri
2862    }
2863
2864    /// The same unit, whatever its colour: by its uri, or by its name when it
2865    /// has none.
2866    fn same_unit(&self, other: &StrataEntry) -> bool {
2867        match (&self.unit_uri, &other.unit_uri) {
2868            (Some(a), Some(b)) => a == b,
2869            (None, None) => self.unit == other.unit,
2870            _ => false,
2871        }
2872    }
2873}
2874
2875/// The title each entry is written with: the unit's, and - when the same unit
2876/// shows in another entry too, in another colour - the names of the packages
2877/// whose wells show it in this one. One place, read by the width and by the
2878/// drawing, so the box is as wide as what it says.
2879fn strata_titles(entries: &[StrataEntry], view: &View) -> Vec<String> {
2880    let name = |store: &str| {
2881        view.store_names
2882            .iter()
2883            .find(|(id, _)| id == store)
2884            .map(|(_, name)| name.clone())
2885            .unwrap_or_else(|| store.to_string())
2886    };
2887    entries
2888        .iter()
2889        .enumerate()
2890        .map(|(i, e)| {
2891            let title = unit_title(&e.unit);
2892            let twice = entries
2893                .iter()
2894                .enumerate()
2895                .any(|(j, other)| j != i && other.same_unit(e));
2896            if twice && !e.stores.is_empty() {
2897                format!(
2898                    "{title} - {}",
2899                    e.stores.iter().map(|s| name(s)).collect::<Vec<_>>().join(", ")
2900                )
2901            } else {
2902                title
2903            }
2904        })
2905        .collect()
2906}
2907
2908fn painted(view: &View, w: &WellItem) -> bool {
2909    paint_of(view, w).is_some_and(|(paint, _)| crate::paint::check(w.path.len(), paint, &view.joints).is_ok())
2910}
2911
2912/// The units the wells could show in a legend, in stratigraphic order - top
2913/// down along the wells, so the younger first. Only an interval with a colour of
2914/// its own counts (without one it takes its well's, which says nothing of the
2915/// unit), under strata colouring, on a well the scene does not paint (its
2916/// pieces carry no unit). The order is the one the wells agree on: a unit
2917/// comes before the one under it in any well; where they disagree, or where no
2918/// well relates two units, the first met (wells left to right) comes first.
2919fn strata_units(wells: &[&WellItem], view: &View) -> Vec<StrataEntry> {
2920    if view.well_colouring != WellColouring::Strata {
2921        return Vec::new();
2922    }
2923    let mut nodes: Vec<StrataEntry> = Vec::new();
2924    let mut edges: Vec<(usize, usize)> = Vec::new();
2925    for w in wells {
2926        if painted(view, w) || w.md.as_ref().is_none_or(|md| md.len() != w.path.len()) {
2927            continue;
2928        }
2929        let mut intervals: Vec<&Interval> = w
2930            .intervals
2931            .iter()
2932            .filter(|iv| own_colour(iv, &w.object.store, view).is_some())
2933            .collect();
2934        intervals.sort_by(|a, b| a.from.total_cmp(&b.from));
2935        let mut previous: Option<usize> = None;
2936        for iv in intervals {
2937            let colour = interval_colour(iv, &w.object.store, "", view);
2938            let at = match nodes
2939                .iter()
2940                .position(|n| n.same_line(&iv.unit, &colour, iv.unit_uri.as_deref()))
2941            {
2942                Some(at) => {
2943                    if !nodes[at].stores.contains(&w.object.store) {
2944                        nodes[at].stores.push(w.object.store.clone());
2945                    }
2946                    at
2947                }
2948                None => {
2949                    nodes.push(StrataEntry {
2950                        unit: iv.unit.clone(),
2951                        colour,
2952                        unit_uri: iv.unit_uri.clone(),
2953                        stores: vec![w.object.store.clone()],
2954                    });
2955                    nodes.len() - 1
2956                }
2957            };
2958            if let Some(before) = previous.filter(|&p| p != at) {
2959                edges.push((before, at));
2960            }
2961            previous = Some(at);
2962        }
2963    }
2964    // A stable topological order; a cycle (wells that disagree) breaks at the
2965    // first unit met.
2966    let mut waiting: Vec<usize> = vec![0; nodes.len()];
2967    for &(_, to) in &edges {
2968        waiting[to] += 1;
2969    }
2970    let mut done = vec![false; nodes.len()];
2971    let mut order = Vec::with_capacity(nodes.len());
2972    while order.len() < nodes.len() {
2973        let next = (0..nodes.len())
2974            .find(|&i| !done[i] && waiting[i] == 0)
2975            .or_else(|| (0..nodes.len()).find(|&i| !done[i]));
2976        let Some(next) = next else { break };
2977        done[next] = true;
2978        order.push(nodes[next].clone());
2979        for &(from, to) in &edges {
2980            if from == next && waiting[to] > 0 {
2981                waiting[to] -= 1;
2982            }
2983        }
2984    }
2985    order
2986}
2987
2988/// Whether some part of a well's path (by MD) is covered by no interval - or the
2989/// well has no MD to lay intervals on, so none of it is.
2990fn has_unstratified(w: &WellItem) -> bool {
2991    unstratified_spans(w).is_none_or(|spans| !spans.is_empty())
2992}
2993
2994/// Why the strata legend asked for is not drawn - one note, naming the cause.
2995fn strata_refusal(wells: &[&WellItem], view: &View) -> String {
2996    if view.well_colouring != WellColouring::Strata {
2997        return "strata legend not drawn: the wells are not coloured by strata".to_string();
2998    }
2999    if wells.is_empty() {
3000        return "strata legend not drawn: no well is drawn".to_string();
3001    }
3002    let by_scene = wells.iter().filter(|w| painted(view, w)).count();
3003    if by_scene > 0 {
3004        format!(
3005            "strata legend not drawn: no unit with a colour of its own on the wells drawn \
3006             ({by_scene} well(s) are painted by the scene, whose colours carry no unit)"
3007        )
3008    } else {
3009        "strata legend not drawn: no well shows a stratigraphic unit with a colour of its own".to_string()
3010    }
3011}
3012
3013/// The strata legend's width for these units, pixels - `0` when there is none.
3014fn strata_width(entries: &[StrataEntry], no_strata: bool, view: &View) -> f64 {
3015    if entries.is_empty() {
3016        return 0.0;
3017    }
3018    let widest = strata_titles(entries, view)
3019        .iter()
3020        .map(|title| labels::width_px(title, SMALL_PX))
3021        .chain(no_strata.then(|| labels::width_px(NO_STRATA_TITLE, SMALL_PX)))
3022        .fold(labels::width_px("Stratigraphy", FONT_PX), f64::max);
3023    (widest + 36.0).clamp(LEGEND_MIN_PX, LEGEND_MAX_PX)
3024}
3025
3026/// What the legend calls the portions no interval covers, when they are drawn in
3027/// a style of their own.
3028const NO_STRATA_TITLE: &str = "No strata";
3029
3030fn unit_title(unit: &str) -> String {
3031    if unit.is_empty() {
3032        "(unnamed unit)".to_string()
3033    } else {
3034        unit.to_string()
3035    }
3036}
3037
3038/// The strata legend, in a box at `x` from `top` down to at most `bottom`: a
3039/// heading, then a swatch and a title per unit - **one object per unit**, with
3040/// the unit's title and the colour its interval is drawn in. What does not fit is
3041/// said on its last line. Nothing when there is no unit: never an empty box.
3042fn strata_legend(
3043    entries: &[StrataEntry],
3044    view: &View,
3045    no_strata: Option<&UnstratifiedStyle>,
3046    x: f64,
3047    width: f64,
3048    top: f64,
3049    bottom: f64,
3050) -> Vec<LaidObject> {
3051    if entries.is_empty() {
3052        return Vec::new();
3053    }
3054    let room = (((bottom - top) - LINE_PX - 8.0) / LINE_PX).floor().max(1.0) as usize;
3055    let extra = usize::from(no_strata.is_some());
3056    let shown = if entries.len() + extra > room {
3057        room.saturating_sub(1 + extra)
3058    } else {
3059        entries.len()
3060    };
3061    let height = LINE_PX * (shown + 1 + extra + usize::from(shown < entries.len())) as f64 + 10.0;
3062    let decor = |name: &str, title: String, shapes: Vec<Shape>| LaidObject {
3063        attr: Attr::Decor(name.into()),
3064        kind: name.into(),
3065        title,
3066        highlighted: false,
3067        at: None,
3068        shapes,
3069        intervals: Vec::new(),
3070    };
3071    let mut frame = vec![
3072        Shape::Polygon {
3073            points: vec![
3074                [x, top],
3075                [x + width, top],
3076                [x + width, top + height],
3077                [x, top + height],
3078            ],
3079            fill: "#ffffff".into(),
3080            opacity: 0.92,
3081            stroke: Some("#999999".into()),
3082            dash: false,
3083        },
3084        Shape::Text {
3085            at: [x + 8.0, top + LINE_PX],
3086            text: "Stratigraphy".into(),
3087            anchor: Anchor::Start,
3088            size: FONT_PX,
3089            fill: INK.into(),
3090        },
3091    ];
3092    if shown < entries.len() {
3093        frame.push(Shape::Text {
3094            at: [x + 8.0, top + LINE_PX * ((shown + extra) as f64 + 2.0)],
3095            text: format!("+ {} more units", entries.len() - shown),
3096            anchor: Anchor::Start,
3097            size: SMALL_PX,
3098            fill: INK.into(),
3099        });
3100    }
3101    let mut out = vec![decor("strata-box", String::new(), frame)];
3102    let titles = strata_titles(entries, view);
3103    for (i, e) in entries.iter().take(shown).enumerate() {
3104        let y = top + LINE_PX * (i as f64 + 2.0);
3105        let title = titles[i].clone();
3106        let mut entry = decor(
3107            "strata-entry",
3108            e.unit.clone(),
3109            vec![
3110                Shape::Polygon {
3111                    points: vec![
3112                        [x + 8.0, y - 9.0],
3113                        [x + 22.0, y - 9.0],
3114                        [x + 22.0, y - 1.0],
3115                        [x + 8.0, y - 1.0],
3116                    ],
3117                    fill: e.colour.clone(),
3118                    opacity: 1.0,
3119                    stroke: Some("#666666".into()),
3120                    dash: false,
3121                },
3122                Shape::Text {
3123                    at: [x + 28.0, y],
3124                    text: labels::fit(&title, SMALL_PX, (width - 28.0 - 6.0).max(20.0)),
3125                    anchor: Anchor::Start,
3126                    size: SMALL_PX,
3127                    fill: INK.into(),
3128                },
3129            ],
3130        );
3131        if let Some(uri) = &e.unit_uri {
3132            entry.attr = Attr::UnitEntry("strata-entry".into(), uri.clone(), e.stores.clone());
3133        }
3134        out.push(entry);
3135    }
3136    if let Some(style) = no_strata {
3137        // After the units: the portions no unit covers, in the style they are drawn in.
3138        let y = top + LINE_PX * (shown as f64 + 2.0);
3139        let swatch = [
3140            [x + 8.0, y - 9.0],
3141            [x + 22.0, y - 9.0],
3142            [x + 22.0, y - 1.0],
3143            [x + 8.0, y - 1.0],
3144        ];
3145        let boxed = |fill: String| Shape::Polygon {
3146            points: swatch.to_vec(),
3147            fill,
3148            opacity: 1.0,
3149            stroke: Some("#666666".into()),
3150            dash: false,
3151        };
3152        let mut shapes = match style {
3153            UnstratifiedStyle::Solid { colour } => vec![boxed(colour.clone())],
3154            UnstratifiedStyle::Dashed { colour, dash, gap } => vec![Shape::Dashed {
3155                points: vec![[x + 6.0, y - 5.0], [x + 24.0, y - 5.0]],
3156                stroke: colour.clone(),
3157                width: 3.0,
3158                dash: *dash,
3159                gap: *gap,
3160            }],
3161            UnstratifiedStyle::Hatched { .. } => vec![boxed(hatch_ref())],
3162            UnstratifiedStyle::Hidden => Vec::new(),
3163        };
3164        shapes.push(Shape::Text {
3165            at: [x + 28.0, y],
3166            text: labels::fit(NO_STRATA_TITLE, SMALL_PX, (width - 28.0 - 6.0).max(20.0)),
3167            anchor: Anchor::Start,
3168            size: SMALL_PX,
3169            fill: INK.into(),
3170        });
3171        out.push(decor("strata-none", String::new(), shapes));
3172    }
3173    out
3174}
3175
3176/// One line of the legend of codes.
3177struct LegendEntry {
3178    code: String,
3179    title: String,
3180    colour: String,
3181    round: bool,
3182}
3183
3184/// The legend's width for these entries, pixels - `0` when there is none.
3185fn legend_width(entries: &[LegendEntry]) -> f64 {
3186    if entries.is_empty() {
3187        return 0.0;
3188    }
3189    let widest = entries
3190        .iter()
3191        .map(|e| labels::width_px(&e.code, SMALL_PX) + 6.0 + labels::width_px(&e.title, SMALL_PX))
3192        .fold(0.0, f64::max);
3193    (widest + 30.0).clamp(LEGEND_MIN_PX, LEGEND_MAX_PX)
3194}
3195
3196/// The legend of codes, in a box at `x`, from `top` down to at most `bottom`: a
3197/// swatch, the code, the title cut to the box. What does not fit is said on its
3198/// last line, never dropped silently.
3199fn legend(mut entries: Vec<LegendEntry>, x: f64, width: f64, top: f64, bottom: f64) -> Option<LaidObject> {
3200    if entries.is_empty() {
3201        return None;
3202    }
3203    let family = |c: &str| {
3204        ["W", "H", "F", "S", "G"]
3205            .iter()
3206            .position(|f| c.starts_with(f))
3207            .unwrap_or(9)
3208    };
3209    let number = |c: &str| c[1..].parse::<usize>().unwrap_or(0);
3210    entries.sort_by(|a, b| {
3211        family(&a.code)
3212            .cmp(&family(&b.code))
3213            .then(number(&a.code).cmp(&number(&b.code)))
3214    });
3215    let room = (((bottom - top) - LINE_PX - 8.0) / LINE_PX).floor().max(1.0) as usize;
3216    let shown = if entries.len() > room {
3217        room.saturating_sub(1)
3218    } else {
3219        entries.len()
3220    };
3221    let height = LINE_PX * (shown + 1 + usize::from(shown < entries.len())) as f64 + 10.0;
3222    let mut shapes = vec![
3223        Shape::Polygon {
3224            points: vec![
3225                [x, top],
3226                [x + width, top],
3227                [x + width, top + height],
3228                [x, top + height],
3229            ],
3230            fill: "#ffffff".into(),
3231            opacity: 0.92,
3232            stroke: Some("#999999".into()),
3233            dash: false,
3234        },
3235        Shape::Text {
3236            at: [x + 8.0, top + LINE_PX],
3237            text: "Legend".into(),
3238            anchor: Anchor::Start,
3239            size: FONT_PX,
3240            fill: INK.into(),
3241        },
3242    ];
3243    let code_w = entries
3244        .iter()
3245        .map(|e| labels::width_px(&e.code, SMALL_PX))
3246        .fold(0.0, f64::max);
3247    for (i, e) in entries.iter().take(shown).enumerate() {
3248        let y = top + LINE_PX * (i as f64 + 2.0);
3249        if e.round {
3250            shapes.push(Shape::Circle {
3251                c: [x + 12.0, y - 4.0],
3252                r: 3.5,
3253                fill: e.colour.clone(),
3254            });
3255        } else {
3256            shapes.push(Shape::Polyline {
3257                points: vec![[x + 6.0, y - 4.0], [x + 18.0, y - 4.0]],
3258                stroke: e.colour.clone(),
3259                width: 3.0,
3260                dash: false,
3261            });
3262        }
3263        shapes.push(Shape::Text {
3264            at: [x + 24.0, y],
3265            text: e.code.clone(),
3266            anchor: Anchor::Start,
3267            size: SMALL_PX,
3268            fill: INK.into(),
3269        });
3270        let room = width - 24.0 - code_w - 12.0;
3271        shapes.push(Shape::Text {
3272            at: [x + 24.0 + code_w + 6.0, y],
3273            text: labels::fit(&e.title, SMALL_PX, room.max(20.0)),
3274            anchor: Anchor::Start,
3275            size: SMALL_PX,
3276            fill: INK.into(),
3277        });
3278    }
3279    if shown < entries.len() {
3280        shapes.push(Shape::Text {
3281            at: [x + 8.0, top + LINE_PX * (shown as f64 + 2.0)],
3282            text: format!("+ {} more - in the objects list", entries.len() - shown),
3283            anchor: Anchor::Start,
3284            size: SMALL_PX,
3285            fill: INK.into(),
3286        });
3287    }
3288    Some(LaidObject {
3289        attr: Attr::Decor("legend".into()),
3290        kind: "legend".into(),
3291        title: "Legend".into(),
3292        highlighted: false,
3293        at: None,
3294        shapes,
3295        intervals: Vec::new(),
3296    })
3297}