pub(crate) mod fill;
mod labels;
mod marking;
mod ticks;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::model::{
AxisMode, Catalogue, CutSource, EnvelopeItem, FORMAT, FillChoice, Inputs, Interval, Item, LabelMode,
Mark, NEUTRAL_COLOUR, Outcome, Paint, Plan, Section, SectionKey, Side, Target, TraceItem, TraceRole,
UnstratifiedStyle, View, WellColouring, WellItem, Window,
};
use fill::Span;
#[derive(Clone, Debug, PartialEq)]
pub struct Layout {
pub target: Target,
pub title: String,
pub size: [f64; 2],
pub background: String,
pub frame: Rect,
pub window: Window,
pub whole: Window,
pub scale: [f64; 2],
pub exaggeration: Option<f64>,
pub exaggeration_text: String,
pub origin_text: String,
pub origin_x: f64,
pub x_axis: Axis,
pub y_axis: Axis,
pub layers: Vec<Layer>,
pub warnings: Vec<String>,
pub folded_labels: usize,
pub note_lines: usize,
pub metadata: Metadata,
pub left_shift: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Axis {
pub title: String,
pub mode: AxisMode,
pub ticks: Vec<Tick>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Tick {
pub value: f64,
pub at: f64,
pub label: String,
pub sublabel: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Layer {
pub name: &'static str,
pub clipped: bool,
pub objects: Vec<LaidObject>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Attr {
Key(String),
Section(String),
FillOf(String),
LabelOf(String),
Decor(String),
UnitEntry(String, String, Vec<String>),
Marker {
name: String,
key: Option<String>,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct LaidObject {
pub attr: Attr,
pub kind: String,
pub title: String,
pub highlighted: bool,
pub at: Option<[f64; 2]>,
pub shapes: Vec<Shape>,
pub intervals: Vec<LaidInterval>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LaidInterval {
pub from: f64,
pub to: f64,
pub unit: String,
pub unit_uri: Option<String>,
pub colour: String,
pub width: f64,
pub points: Vec<[f64; 2]>,
pub tick: Option<[[f64; 2]; 2]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Anchor {
Start,
Middle,
End,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Shape {
Polyline {
points: Vec<[f64; 2]>,
stroke: String,
width: f64,
dash: bool,
},
Dashed {
points: Vec<[f64; 2]>,
stroke: String,
width: f64,
dash: f64,
gap: f64,
},
Polygon {
points: Vec<[f64; 2]>,
fill: String,
opacity: f64,
stroke: Option<String>,
dash: bool,
},
Circle {
c: [f64; 2],
r: f64,
fill: String,
},
Symbol {
at: [f64; 2],
kind: String,
size: f64,
fill: String,
stroke: String,
},
Text {
at: [f64; 2],
text: String,
anchor: Anchor,
size: f64,
fill: String,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Metadata {
pub format: u32,
pub version: String,
pub inputs: Inputs,
pub view: View,
}
impl Layout {
pub fn written_warnings(&self) -> Vec<String> {
let room = self.note_lines;
if self.warnings.len() <= room {
return self.warnings.clone();
}
if room == 0 {
return Vec::new();
}
let mut out: Vec<String> = self.warnings[..room - 1].to_vec();
let hidden = self.warnings.len() - (room - 1);
out.push(if out.is_empty() {
format!("{hidden} notes - not shown at this size")
} else {
format!("+ {hidden} more notes - not shown at this size")
});
out
}
pub fn keys(&self) -> Vec<&str> {
self.layers
.iter()
.flat_map(|l| l.objects.iter())
.filter_map(|o| {
if let Attr::Key(k) = &o.attr {
Some(k.as_str())
} else {
None
}
})
.collect()
}
pub fn layer(&self, name: &str) -> Option<&Layer> {
self.layers.iter().find(|l| l.name == name)
}
}
const MARGIN_LEFT: f64 = 64.0;
const MARGIN_RIGHT: f64 = 20.0;
const MARGIN_TOP: f64 = 72.0;
const MARGIN_BOTTOM: f64 = 46.0;
const LINE_PX: f64 = 14.0;
const PLAN_TOP: f64 = 36.0;
const PLOT_SHARE: f64 = 0.5;
const PLOT_MIN_PX: f64 = 40.0;
const FONT_PX: f64 = 11.0;
const SMALL_PX: f64 = 10.0;
const FILL_OPACITY: f64 = 0.35;
const LEFT_COLOUR: &str = "#1f77b4";
const RIGHT_COLOUR: &str = "#d95f02";
const INK: &str = "#222222";
const MARK_COLOUR: &str = "#7b1fa2";
pub fn layout(catalogue: &Catalogue, view: &View) -> Layout {
match view.target {
Target::Section { key } => section(catalogue, view, key),
Target::Plan => plan(catalogue, view),
}
}
fn metadata(catalogue: &Catalogue, view: &View) -> Metadata {
Metadata {
format: FORMAT,
version: env!("CARGO_PKG_VERSION").to_string(),
inputs: catalogue.inputs.clone(),
view: view.clone(),
}
}
fn unit_of(catalogue: &Catalogue) -> String {
catalogue
.inputs
.system
.as_ref()
.map(|s| s.unit.clone())
.unwrap_or_default()
}
fn common_warnings(catalogue: &Catalogue) -> Vec<String> {
let mut out = Vec::new();
let refused = catalogue
.objects
.iter()
.filter(|r| matches!(r.outcome, Outcome::Refused { .. }))
.count();
if refused > 0 {
out.push(format!("{refused} objects refused - see the objects list"));
}
let set_aside = catalogue
.plan
.wells
.iter()
.filter(|w| w.rejected.is_some())
.count();
if set_aside > 0 {
out.push(format!("{set_aside} wells set aside - projected nowhere"));
}
out
}
#[derive(Clone, Copy)]
struct Map {
window: Window,
frame: Rect,
sx: f64,
sy: f64,
}
impl Map {
fn px(&self, p: [f64; 2]) -> [f64; 2] {
[
self.frame.x + (p[0] - self.window.x[0]) * self.sx,
self.frame.y + self.frame.h - (p[1] - self.window.y[0]) * self.sy,
]
}
fn points(&self, pts: &[[f64; 2]]) -> Vec<[f64; 2]> {
dedupe(
pts.iter()
.filter(|p| p[0].is_finite() && p[1].is_finite())
.map(|&p| self.px(p))
.collect(),
)
}
}
fn dedupe(points: Vec<[f64; 2]>) -> Vec<[f64; 2]> {
let mut out: Vec<[f64; 2]> = Vec::with_capacity(points.len());
for p in points {
if out
.last()
.is_none_or(|q| (q[0] - p[0]).abs() >= 0.05 || (q[1] - p[1]).abs() >= 0.05)
{
out.push(p);
}
}
out
}
#[derive(Default)]
struct Extent {
x: Option<[f64; 2]>,
y: Option<[f64; 2]>,
}
impl Extent {
fn add(&mut self, p: [f64; 2]) {
if !(p[0].is_finite() && p[1].is_finite()) {
return;
}
let grow = |r: &mut Option<[f64; 2]>, v: f64| {
*r = Some(match *r {
Some([a, b]) => [a.min(v), b.max(v)],
None => [v, v],
})
};
grow(&mut self.x, p[0]);
grow(&mut self.y, p[1]);
}
fn add_all(&mut self, pts: &[[f64; 2]]) {
pts.iter().for_each(|&p| self.add(p));
}
}
fn padded(range: Option<[f64; 2]>, margin: f64) -> [f64; 2] {
let [a, b] = range.unwrap_or([0.0, 1.0]);
let span = b - a;
if span <= f64::EPSILON * a.abs().max(1.0) {
return [a - 0.5, b + 0.5];
}
[a - span * margin, b + span * margin]
}
fn frame_of(view: &View, lines: usize, right: f64, left: f64, top: f64) -> Rect {
let [w, h] = view.size_px;
let bottom = MARGIN_BOTTOM + LINE_PX * lines as f64;
Rect {
x: MARGIN_LEFT + left,
y: top,
w: (w - MARGIN_LEFT - left - MARGIN_RIGHT - right).max(10.0),
h: (h - top - bottom).max(10.0),
}
}
fn budget(
h: f64,
wanted_levels: usize,
wanted_lines: usize,
top_for: impl Fn(usize) -> f64,
) -> (f64, usize, usize) {
let floor = (h * PLOT_SHARE).max(PLOT_MIN_PX);
let (mut levels, mut lines) = (wanted_levels, wanted_lines);
loop {
let plot = h - top_for(levels) - MARGIN_BOTTOM - LINE_PX * lines as f64;
if plot >= floor {
break;
}
if lines > 1 {
lines -= 1;
} else if levels > 1 {
levels -= 1;
} else {
break;
}
}
(top_for(levels), levels, lines)
}
fn is_hidden(view: &View, key: &str) -> bool {
view.hidden.iter().any(|h| h == key)
}
fn width_of(view: &View, key: &str, default: f64) -> f64 {
view.widths
.iter()
.find(|(k, _)| k == key)
.map(|(_, w)| *w)
.filter(|w| valid_width(*w))
.unwrap_or(default)
}
fn valid_width(w: f64) -> bool {
w.is_finite() && w > 0.0 && w <= crate::model::MAX_WIDTH_PX
}
fn width_warnings(view: &View) -> Vec<String> {
view.widths
.iter()
.filter(|(_, w)| !valid_width(*w))
.map(|(k, w)| {
let uri = k.split('\0').next_back().unwrap_or(k);
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)
})
.collect()
}
pub(crate) fn colour_of(view: &View, key: &str) -> Option<String> {
view.colours
.iter()
.find(|(k, _)| k == key)
.map(|(_, c)| c.clone())
}
fn key_text(key: SectionKey) -> String {
match key {
SectionKey::Long => "long".to_string(),
SectionKey::Cross { index } => format!("cross-{index}"),
}
}
fn find_section(catalogue: &Catalogue, key: SectionKey) -> Option<&Section> {
match key {
SectionKey::Long => Some(&catalogue.long),
SectionKey::Cross { .. } => catalogue.crosses.iter().find(|s| s.key == key),
}
}
fn span_extent(span: &[[f64; 2]]) -> f64 {
match (span.first(), span.last()) {
(Some(a), Some(b)) => (b[0] - a[0]).abs(),
_ => 0.0,
}
}
pub(crate) fn fallback_colour(uuid: &str, role: TraceRole) -> String {
if uuid.is_empty() {
default_trace_colour(role).to_string()
} else {
energyml_domain::color::derived_colour(uuid)
}
}
fn envelope_colour(view: &View, e: &EnvelopeItem) -> String {
colour_of(view, &e.object.key())
.or_else(|| e.colour.clone())
.unwrap_or_else(|| fallback_colour(&e.object.uuid, TraceRole::Other))
}
pub(crate) fn trace_colour(view: &View, t: &TraceItem) -> String {
colour_of(view, &t.object.key())
.or_else(|| t.colour.clone())
.unwrap_or_else(|| fallback_colour(&t.object.uuid, t.role))
}
fn default_trace_colour(role: TraceRole) -> &'static str {
match role {
TraceRole::Horizon => "#2e7d32",
TraceRole::Fault => "#c62828",
TraceRole::Other => NEUTRAL_COLOUR,
}
}
struct KeptTrace<'a> {
item: &'a TraceItem,
key: String,
colour: String,
spans: Vec<Span>,
}
fn section(catalogue: &Catalogue, view: &View, key: SectionKey) -> Layout {
let mut warnings = common_warnings(catalogue);
warnings.extend(width_warnings(view));
let unit = unit_of(catalogue);
let title = match key {
SectionKey::Long => "Long section".to_string(),
SectionKey::Cross { index } => format!("Cross section {index}"),
};
let sec = find_section(catalogue, key);
if sec.is_none() {
warnings.push(format!("{title}: not in the catalogue - nothing to draw"));
}
let empty = Section {
key,
trace: Vec::new(),
length: 0.0,
marks: Vec::new(),
band: Vec::new(),
items: Vec::new(),
z_range: None,
};
let sec = sec.unwrap_or(&empty);
let portion = view.portion.map(|[a, b]| if a <= b { [a, b] } else { [b, a] });
let (plo, phi) = portion.map_or((f64::NEG_INFINITY, f64::INFINITY), |[a, b]| (a, b));
let in_portion = |s: f64| s >= plo && s <= phi;
let mut wells: Vec<&WellItem> = Vec::new();
let mut traces: Vec<KeptTrace> = Vec::new();
#[allow(clippy::type_complexity)]
let mut envelopes: Vec<(
&EnvelopeItem,
Vec<Span>,
Vec<Span>,
Vec<Vec<[f64; 2]>>,
Vec<Vec<[f64; 2]>>,
)> = Vec::new();
let mut time_drawn = false;
for item in &sec.items {
match item {
Item::Well(w) => {
let k = w.object.key();
if !is_hidden(view, &k) && in_portion(w.s) {
wells.push(w);
}
}
Item::Trace(t) => {
let k = t.object.key();
if is_hidden(view, &k) {
continue;
}
let spans: Vec<Span> = t
.spans
.iter()
.filter_map(|s| fill::clip_span(s, plo, phi))
.collect();
if spans.is_empty() {
continue;
}
time_drawn |= t.time;
let colour = trace_colour(view, t);
traces.push(KeptTrace {
item: t,
key: k,
colour,
spans,
});
}
Item::Envelope(e) => {
let k = e.object.key();
if is_hidden(view, &k) {
continue;
}
let top: Vec<Span> = e
.top
.iter()
.filter_map(|s| fill::clip_span(s, plo, phi))
.collect();
let bottom: Vec<Span> = e
.bottom
.iter()
.filter_map(|s| fill::clip_span(s, plo, phi))
.collect();
let fills: Vec<_> = e
.fills
.iter()
.map(|p| fill::clip_polygon(p, plo, phi))
.filter(|p| !p.is_empty())
.collect();
let faults: Vec<_> = e
.faults
.iter()
.filter_map(|s| fill::clip_span(s, plo, phi))
.collect();
if top.is_empty() && bottom.is_empty() && fills.is_empty() && faults.is_empty() {
continue;
}
time_drawn |= e.time;
envelopes.push((e, top, bottom, fills, faults));
}
}
}
if time_drawn {
warnings.push(match catalogue.inputs.time_factor {
Some(f) => {
format!("surfaces in time are drawn on the depth axis at {f} depth units per time unit")
}
None => "surfaces in time are drawn on the depth axis without a time scale".to_string(),
});
}
for (code, one, many) in [
(crate::erosion::CODE, "erosion suspected", "erosions suspected"),
("crossingMismatch", "crossing mismatch", "crossing mismatches"),
] {
let count = catalogue
.notes
.iter()
.filter(|n| n.code == code && n.sections.contains(&key))
.count();
if count > 0 {
warnings.push(format!(
"{count} {} - see the notes",
if count == 1 { one } else { many }
));
}
}
let whole = {
let mut e = Extent::default();
e.add([portion.map_or(0.0, |p| p[0]), 0.0]);
e.add([portion.map_or(sec.length, |p| p[1]), 0.0]);
let mut z = Extent::default();
for w in &wells {
z.add(w.head);
z.add_all(&w.path);
}
for t in &traces {
t.spans.iter().for_each(|s| z.add_all(s));
}
for (_, top, bottom, fills, faults) in &envelopes {
top.iter().chain(bottom).chain(faults).for_each(|s| z.add_all(s));
fills.iter().for_each(|s| z.add_all(s));
}
Window {
x: padded(e.x, 0.02),
y: padded(z.y, 0.05),
}
};
let window = view.window.unwrap_or(whole);
wells.retain(|w| {
view.window.is_none()
|| w.path
.iter()
.chain(std::iter::once(&w.head))
.any(|p| p[0] >= window.x[0] && p[0] <= window.x[1])
});
let mut mode = view.axis;
if mode == AxisMode::Geodesic {
warnings.push(
"geodesic axis not available: the horizontal axis shows the projected distance \
(a scale factor along the trace from energyml-proj is needed)"
.to_string(),
);
mode = AxisMode::Projected;
}
for w in &wells {
if let Some((paint, _)) = paint_of(view, w)
&& let Err(why) = crate::paint::check(w.path.len(), paint, &view.joints)
{
warnings.push(paint_refusal(w, &why));
}
}
let strata = if view.strata_legend {
strata_units(&wells, view)
} else {
Vec::new()
};
if view.strata_legend && strata.is_empty() {
warnings.push(strata_refusal(&wells, view));
}
let unstratified = effective_style(view, &mut warnings);
if unstratified == UnstratifiedStyle::Hidden && view.well_colouring == WellColouring::Strata {
let lost = wells
.iter()
.filter(|w| !painted(view, w) && has_unstratified(w))
.count();
if lost > 0 {
warnings.push(format!(
"{lost} well(s) drawn only where a stratigraphic unit is known - the portions with no strata information are not drawn"
));
}
}
let no_strata = (view.strata_legend
&& !strata.is_empty()
&& unstratified != UnstratifiedStyle::Hidden
&& !unstratified.is_default()
&& wells.iter().any(|w| !painted(view, w) && has_unstratified(w)))
.then(|| unstratified.clone());
let marked = marking::resolve(view, &wells, &traces, &mut warnings);
let strata_w = strata_width(&strata, no_strata.is_some(), view);
let left_shift = if strata_w > 0.0 {
strata_w + STRATA_GAP_PX
} else {
0.0
};
let labelled = view.show.names || view.show.distances;
let reserved = if view.show.notes {
warnings.len() + usize::from(labelled && !wells.is_empty())
} else {
0
};
let coded = (view.labels == LabelMode::Codes).then(|| codes(catalogue));
let mut entries: Vec<LegendEntry> = Vec::new();
if let Some(coded) = &coded {
for w in &wells {
if let Some(code) = coded.get(&w.object.key()) {
entries.push(LegendEntry {
code: code.clone(),
title: well_name(&w.object),
colour: NEUTRAL_COLOUR.into(),
round: true,
});
}
}
for t in &traces {
if let Some(code) = coded.get(&t.key) {
entries.push(LegendEntry {
code: code.clone(),
title: t.item.object.title.clone(),
colour: t.colour.clone(),
round: false,
});
}
}
for (e, ..) in &envelopes {
if let Some(code) = coded.get(&e.object.key()) {
entries.push(LegendEntry {
code: code.clone(),
title: e.object.title.clone(),
colour: envelope_colour(view, e),
round: false,
});
}
}
}
let legend_w = legend_width(&entries);
let markers_w = marked.width();
let right_w = legend_w.max(markers_w);
let label_lines = usize::from(view.show.names) + usize::from(view.show.distances);
let banded = label_lines > 0 && !wells.is_empty();
let line_h = (label_lines as f64 - 1.0) * LINE_PX + FONT_PX + 3.0;
let top_for = |levels: usize| {
if banded {
TITLE_BAND_PX + levels as f64 * (line_h + 3.0) + 8.0
} else {
MARGIN_TOP
}
};
let (top, _, note_lines) = budget(view.size_px[1], HEAD_BAND_LEVELS, reserved, top_for);
let frame = frame_of(
view,
note_lines,
if right_w > 0.0 { right_w + 12.0 } else { 0.0 },
left_shift,
top,
);
let sx = frame.w / (window.x[1] - window.x[0]);
let sy = frame.h / (window.y[1] - window.y[0]);
let map = Map {
window,
frame,
sx,
sy,
};
let exaggeration = sy / sx;
let exaggeration_text = format!("vertical exaggeration ×{}", round_sig(exaggeration));
let (origin_text, origin_x) = origin_line(
view.show.origin,
sec.trace.first().copied(),
catalogue.inputs.system.as_ref(),
&title,
&exaggeration_text,
&frame,
&mut warnings,
);
let mut layers = Vec::new();
let mut env_fills = Vec::new();
let mut env_lines = Vec::new();
for (e, top, bottom, fills, faults) in &envelopes {
let key = e.object.key();
let colour = envelope_colour(view, e);
let polygons: Vec<Shape> = fills
.iter()
.map(|poly| Shape::Polygon {
points: map.points(poly),
fill: colour.clone(),
opacity: FILL_OPACITY,
stroke: None,
dash: false,
})
.collect();
if !polygons.is_empty() {
env_fills.push(LaidObject {
attr: Attr::FillOf(key.clone()),
kind: "fill".into(),
title: e.object.title.clone(),
highlighted: false,
at: None,
shapes: polygons,
intervals: Vec::new(),
});
}
let lines: Vec<Shape> = top
.iter()
.chain(bottom)
.chain(faults)
.map(|line| Shape::Polyline {
points: map.points(line),
stroke: colour.clone(),
width: width_of(view, &key, 1.0),
dash: false,
})
.collect();
env_lines.push(LaidObject {
highlighted: view.highlight.contains(&key),
attr: Attr::Key(key),
kind: "envelope".into(),
title: e.object.title.clone(),
at: None,
shapes: lines,
intervals: Vec::new(),
});
}
layers.push(Layer {
name: "envelopes",
clipped: true,
objects: env_fills,
});
let lines: Vec<fill::Line<'_>> = traces
.iter()
.map(|t| fill::Line {
key: &t.key,
item: t.item,
spans: &t.spans,
})
.collect();
let mut fill_objects = Vec::new();
for (t, mine) in traces.iter().zip(&lines) {
let Some(FillChoice { up, down, .. }) = view.fills.iter().find(|f| f.key == t.key) else {
continue;
};
let mut shapes = Vec::new();
for (wanted, is_up) in [(*up, true), (*down, false)] {
if !wanted {
continue;
}
for poly in fill::polygons(mine, &lines, is_up) {
shapes.push(Shape::Polygon {
points: map.points(&poly),
fill: t.colour.clone(),
opacity: FILL_OPACITY,
stroke: None,
dash: false,
});
}
}
if !shapes.is_empty() {
fill_objects.push(LaidObject {
attr: Attr::FillOf(t.key.clone()),
kind: "fill".into(),
title: t.item.object.title.clone(),
highlighted: false,
at: None,
shapes,
intervals: Vec::new(),
});
}
}
layers.push(Layer {
name: "fills",
clipped: true,
objects: fill_objects,
});
layers.push(Layer {
name: "outlines",
clipped: true,
objects: env_lines,
});
let erosion_objects = erosion_marks(sec, &traces, [plo, phi], &map);
if !erosion_objects.is_empty() {
layers.push(Layer {
name: "erosions",
clipped: true,
objects: erosion_objects,
});
}
let mut trace_objects: Vec<LaidObject> = Vec::new();
let mut names: Vec<LineName> = Vec::new();
for t in &traces {
let highlighted = view.highlight.contains(&t.key);
let line_width = width_of(view, &t.key, 1.5) + if highlighted { 1.5 } else { 0.0 };
let paint = view
.paints
.iter()
.find(|(k, _)| *k == t.key)
.map(|(_, paint)| paint);
let sources = cut_sources(t.item);
let mut paint_refusal: Option<String> = None;
let mut shapes: Vec<Shape> = Vec::new();
for s in &t.spans {
if let (Some(paint), Some(sources)) = (paint, sources)
&& let Some(from) = source_of_span(t.item, sources, s)
{
let px: Vec<[f64; 2]> = s.iter().map(|&p| map.px(p)).collect();
if px.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
match crate::paint::surfaces::trace_pieces(&px, from, paint) {
Ok(pieces) => {
shapes.extend(pieces.into_iter().map(|piece| Shape::Polyline {
stroke: crate::paint::over(&piece.colour, piece.opacity, &view.background),
points: piece.points,
width: line_width,
dash: false,
}));
continue;
}
Err(why) => {
paint_refusal.get_or_insert(why);
}
}
}
}
shapes.push(Shape::Polyline {
points: map.points(s),
stroke: t.colour.clone(),
width: line_width,
dash: false,
});
}
if let Some(why) = paint_refusal {
warnings.push(format!(
"scene colours refused for {} - {why}; the surface is drawn in its own colour",
t.item.object.title
));
}
if view.show.surface_names
&& let Some(longest) = t
.spans
.iter()
.max_by(|a, b| span_extent(a).total_cmp(&span_extent(b)))
&& let Some(&end) = longest.last()
&& let Some(&end) = map.points(&[end]).first()
{
names.push(LineName {
owner: trace_objects.len(),
end,
text: shown_name(coded.as_ref(), &t.item.object),
colour: t.colour.clone(),
});
}
trace_objects.push(LaidObject {
attr: Attr::Key(t.key.clone()),
kind: match t.item.role {
TraceRole::Horizon => "horizon",
TraceRole::Fault => "fault",
TraceRole::Other => "surface",
}
.into(),
title: t.item.object.title.clone(),
highlighted,
at: None,
shapes,
intervals: Vec::new(),
});
}
let mut mark_shapes = Vec::new();
let mut mark_placer = labels::Placer::new(labels::Rect2 {
x0: frame.x,
y0: frame.y,
x1: frame.x + frame.w,
y1: frame.y + frame.h,
});
let mut marks_unlabelled = 0usize;
for mark in &sec.marks {
let (s, kind, text, show) = match *mark {
Mark::Vertex { s, index } => (s, "vertex", format!("V{index}"), view.show.vertices),
Mark::Crossing { s, other } => {
let text = match other {
SectionKey::Long => "× long".to_string(),
SectionKey::Cross { index } => format!("× cross {index}"),
};
(s, "crossing", text, view.show.crossings)
}
};
if !show || !in_portion(s) || s < window.x[0] || s > window.x[1] {
continue;
}
let x = map.px([s, 0.0])[0];
let (y0, y1) = if kind == "vertex" {
(frame.y + frame.h - 8.0, frame.y + frame.h)
} else {
(frame.y, frame.y + frame.h)
};
mark_shapes.push(Shape::Polyline {
points: vec![[x, y0], [x, y1]],
stroke: MARK_COLOUR.into(),
width: 1.0,
dash: kind == "crossing",
});
let base_y = if kind == "vertex" {
frame.y + frame.h - 10.0
} else {
frame.y + SMALL_PX + 2.0
};
let step = if kind == "vertex" {
-MARK_LEVEL_PX
} else {
MARK_LEVEL_PX
};
let mut forms = vec![text.clone()];
let short = short_mark_text(&text);
if short != text {
forms.push(short);
}
let mut candidates: Vec<(String, labels::Rect2, [f64; 2], Anchor)> = Vec::new();
for level in 0..MARK_LEVELS {
let y = base_y + step * level as f64;
for form in &forms {
let w = labels::width_px(form, SMALL_PX);
for (text_x, anchor, x0) in [
(x + 3.0, Anchor::Start, x + 3.0),
(x - 3.0, Anchor::End, x - 3.0 - w),
] {
let rect = labels::Rect2 {
x0,
y0: y - SMALL_PX,
x1: x0 + w,
y1: y + 2.0,
};
candidates.push((form.clone(), rect, [text_x, y], anchor));
}
}
}
let rects: Vec<labels::Rect2> = candidates.iter().map(|c| c.1).collect();
match mark_placer.place(&rects) {
Some(at) => {
let (text, _, at, anchor) = candidates.swap_remove(at);
mark_shapes.push(Shape::Text {
at,
text,
anchor,
size: SMALL_PX,
fill: MARK_COLOUR.into(),
});
}
None => marks_unlabelled += 1,
}
}
if marks_unlabelled > 0 {
warnings.push(format!(
"{marks_unlabelled} section marks unlabelled - they did not fit"
));
}
let marks_group = if mark_shapes.is_empty() {
Vec::new()
} else {
vec![LaidObject {
attr: Attr::Decor("marks".into()),
kind: "marks".into(),
title: String::new(),
highlighted: false,
at: None,
shapes: mark_shapes,
intervals: Vec::new(),
}]
};
let mark_texts = text_rects(marks_group.iter().flat_map(|o| o.shapes.iter()));
let bounds = labels::Rect2 {
x0: frame.x,
y0: frame.y,
x1: frame.x + frame.w,
y1: frame.y + frame.h,
};
let (placed, names_folded) = line_names(names, bounds, &mark_texts);
for (owner, text) in placed {
trace_objects[owner].shapes.push(text);
}
if names_folded > 0 {
warnings.push(format!("{names_folded} surface names folded - they did not fit"));
}
let contours = marked.contours(&map);
if !contours.is_empty() {
layers.push(Layer {
name: "marker-contours",
clipped: true,
objects: contours,
});
}
layers.push(Layer {
name: "traces",
clipped: true,
objects: trace_objects,
});
let mut well_objects = Vec::new();
for w in &wells {
well_objects.push(laid_well(w, view, &unstratified, &map, &unit, &mut warnings));
}
let drawn_units: std::collections::HashSet<(String, String)> = well_objects
.iter()
.flat_map(|o| o.intervals.iter())
.map(|iv| (iv.unit.clone(), iv.colour.clone()))
.collect();
layers.push(Layer {
name: "wells",
clipped: true,
objects: well_objects,
});
let symbols = marked.symbols(&map);
if !symbols.is_empty() {
layers.push(Layer {
name: "markers",
clipped: true,
objects: symbols,
});
}
layers.push(Layer {
name: "marks",
clipped: true,
objects: marks_group,
});
let (label_objects, folded) = well_labels(&wells, view, &map, &unit, coded.as_ref(), &mark_texts);
layers.push(Layer {
name: "labels",
clipped: false,
objects: label_objects,
});
let legend_box = legend(
entries,
frame.x + frame.w + 12.0,
legend_w,
frame.y,
frame.y + frame.h,
);
let codes_bottom = legend_box.as_ref().and_then(marking::bottom_of);
layers.push(Layer {
name: "legend",
clipped: false,
objects: legend_box.into_iter().collect(),
});
if !marked.is_empty() {
let top = codes_bottom.map_or(frame.y, |b| b + 8.0);
let objects = marked.legend(frame.x + frame.w + 12.0, markers_w, top, frame.y + frame.h);
layers.push(Layer {
name: MARKERS_LAYER,
clipped: false,
objects,
});
}
if folded > 0 {
warnings.push(format!("{folded} well labels folded - they did not fit"));
}
if view.strata_legend {
let shown: Vec<StrataEntry> = strata
.into_iter()
.filter(|e| drawn_units.contains(&(e.unit.clone(), e.colour.clone())))
.collect();
let objects = strata_legend(
&shown,
view,
no_strata.as_ref(),
STRATA_LEFT_PX,
strata_w,
frame.y,
frame.y + frame.h,
);
layers.push(Layer {
name: STRATA_LAYER,
clipped: false,
objects,
});
}
let x_axis = x_axis(sec, &map, mode, &unit);
let y_axis = y_axis(&map, &unit, "z");
Layout {
target: view.target,
title,
size: view.size_px,
background: view.background.clone(),
frame,
window,
whole,
scale: [sx, sy],
exaggeration: Some(exaggeration),
exaggeration_text,
origin_text,
origin_x,
x_axis,
y_axis,
layers,
warnings,
folded_labels: folded,
note_lines,
metadata: metadata(catalogue, view),
left_shift,
}
}
const EROSION_COLOUR: &str = "#d81b60";
fn erosion_marks(
sec: &Section,
traces: &[KeptTrace<'_>],
[plo, phi]: [f64; 2],
map: &Map,
) -> Vec<LaidObject> {
let mut out = Vec::new();
for portion in crate::erosion::of_section(sec) {
let (Some(older), Some(younger)) = (
traces.iter().find(|t| t.item.object == portion.older),
traces.iter().find(|t| t.item.object == portion.younger),
) else {
continue;
};
let (lo, hi) = (portion.s0.max(plo), portion.s1.min(phi));
if hi <= lo {
continue;
}
let part = |t: &KeptTrace<'_>| t.item.spans.iter().find_map(|s| fill::clip_span(s, lo, hi));
let (Some(upper), Some(lower)) = (part(older), part(younger)) else {
continue;
};
let mut ring = upper.clone();
ring.extend(lower.iter().rev().copied());
out.push(LaidObject {
attr: Attr::Decor("erosion".into()),
kind: "erosion".into(),
title: format!("{} / {}", portion.older.title, portion.younger.title),
highlighted: false,
at: None,
shapes: vec![
Shape::Polygon {
points: map.points(&ring),
fill: EROSION_COLOUR.into(),
opacity: FILL_OPACITY,
stroke: None,
dash: false,
},
Shape::Polyline {
points: map.points(&upper),
stroke: EROSION_COLOUR.into(),
width: 3.0,
dash: true,
},
],
intervals: Vec::new(),
});
}
out
}
const MARK_LEVELS: usize = 3;
const MARK_LEVEL_PX: f64 = SMALL_PX + 2.0 + labels::GAP_PX;
fn short_mark_text(text: &str) -> String {
match text.strip_prefix("× cross ") {
Some(n) => format!("×{n}"),
None if text == "× long" => "×L".to_string(),
None => text.to_string(),
}
}
const CHEVRON_EVERY_PX: f64 = 100.0;
const CHEVRON_CLEAR_PX: f64 = 6.0;
const CHEVRON_ALONG_PX: f64 = 3.0;
const CHEVRON_ACROSS_PX: f64 = 3.5;
fn direction_marks(px: &[[f64; 2]], colour: &str) -> Vec<Shape> {
let mut out = Vec::new();
let Some(first) = px.first() else { return out };
for pair in px.windows(2) {
let (a, b) = (pair[0], pair[1]);
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
if len < 2.0 * CHEVRON_CLEAR_PX {
continue;
}
let (ux, uy) = ((b[0] - a[0]) / len, (b[1] - a[1]) / len);
let (nx, ny) = (-uy, ux);
let n = ((len / CHEVRON_EVERY_PX).floor() as usize).max(1);
for i in 0..n {
let t = (i as f64 + 0.5) / n as f64 * len;
let c = [a[0] + ux * t, a[1] + uy * t];
let tip = [c[0] + ux * CHEVRON_ALONG_PX, c[1] + uy * CHEVRON_ALONG_PX];
let back = [c[0] - ux * CHEVRON_ALONG_PX, c[1] - uy * CHEVRON_ALONG_PX];
let left = [back[0] + nx * CHEVRON_ACROSS_PX, back[1] + ny * CHEVRON_ACROSS_PX];
let right = [back[0] - nx * CHEVRON_ACROSS_PX, back[1] - ny * CHEVRON_ACROSS_PX];
out.push(Shape::Polyline {
points: vec![left, tip, right],
stroke: colour.to_string(),
width: 1.2,
dash: false,
});
}
}
if !out.is_empty() {
out.push(Shape::Circle {
c: *first,
r: 2.5,
fill: colour.to_string(),
});
}
out
}
const TITLE_PX: f64 = 14.0;
const TITLE_GAP_PX: f64 = 16.0;
fn origin_line(
on: bool,
origin: Option<[f64; 2]>,
system: Option<&crate::model::System>,
title: &str,
right_text: &str,
frame: &Rect,
warnings: &mut Vec<String>,
) -> (String, f64) {
let (true, Some([x, y])) = (on, origin) else {
return (String::new(), 0.0);
};
if !x.is_finite() || !y.is_finite() {
return (String::new(), 0.0);
}
let code = system.map(|s| format!("EPSG:{}", s.epsg));
let whole = match &code {
Some(c) => format!("origin (s = 0): X {x:.1} · Y {y:.1} - {c}"),
None => format!("origin (s = 0): X {x:.1} · Y {y:.1}"),
};
let left = frame.x + labels::width_px(title, TITLE_PX) * 1.1 + TITLE_GAP_PX;
let right = frame.x + frame.w - labels::width_px(right_text, FONT_PX) - TITLE_GAP_PX;
let room = right - left;
let put = |text: String| {
let w = labels::width_px(&text, FONT_PX);
let x = (frame.x + frame.w / 2.0 - w / 2.0).clamp(left, (right - w).max(left));
(text, x)
};
if labels::width_px(&whole, FONT_PX) <= room {
return put(whole);
}
if let Some(c) = code {
let short = format!("origin: {c}");
if labels::width_px(&short, FONT_PX) <= room {
warnings.push(
"origin position shortened to its EPSG code - the title band is too narrow".to_string(),
);
return put(short);
}
}
warnings.push("origin position not written - the title band is too narrow".to_string());
(String::new(), 0.0)
}
fn round_sig(v: f64) -> String {
if !v.is_finite() || v <= 0.0 {
return "?".to_string();
}
let decimals = (2 - v.log10().floor() as i64).clamp(0, 6) as usize;
let text = format!("{v:.decimals$}");
if text.contains('.') {
text.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
text
}
}
fn x_axis(sec: &Section, map: &Map, mode: AxisMode, unit: &str) -> Axis {
let (step, values) = ticks::values(map.window.x[0], map.window.x[1], map.frame.w / 90.0);
let suffix = if unit.is_empty() {
String::new()
} else {
format!(" ({unit})")
};
let ticks = values
.into_iter()
.map(|v| {
let sublabel = (mode == AxisMode::Coordinates && sec.trace.len() >= 2).then(|| {
let [x, y] = energyml_geometry::fence::point_at(&sec.trace, v);
format!("{x:.0}, {y:.0}")
});
Tick {
value: v,
at: map.px([v, 0.0])[0],
label: ticks::label(v, step),
sublabel,
}
})
.collect();
Axis {
title: match mode {
AxisMode::Coordinates => format!("distance along the trace{suffix} - true coordinates (x, y)"),
_ => format!("distance along the trace{suffix}"),
},
mode,
ticks,
}
}
fn y_axis(map: &Map, unit: &str, name: &str) -> Axis {
let (step, values) = ticks::values(map.window.y[0], map.window.y[1], map.frame.h / 40.0);
let suffix = if unit.is_empty() {
String::new()
} else {
format!(" ({unit})")
};
Axis {
title: format!("{name}{suffix}"),
mode: AxisMode::Projected,
ticks: values
.into_iter()
.map(|v| Tick {
value: v,
at: map.px([0.0, v])[1],
label: ticks::label(v, step),
sublabel: None,
})
.collect(),
}
}
fn well_colour(w: &WellItem, view: &View) -> String {
let side = match w.side {
Side::Left => LEFT_COLOUR,
Side::Right => RIGHT_COLOUR,
};
match view.well_colouring {
WellColouring::Side => side.to_string(),
WellColouring::Scene => {
colour_of(view, &w.object.key()).unwrap_or_else(|| NEUTRAL_COLOUR.to_string())
}
WellColouring::Strata => {
colour_of(view, &w.object.key()).unwrap_or_else(|| NEUTRAL_COLOUR.to_string())
}
}
}
fn cut_sources(item: &TraceItem) -> Option<&[Vec<CutSource>]> {
(!item.sources.is_empty()).then_some(item.sources.as_slice())
}
fn source_of_span<'a>(
item: &TraceItem,
sources: &'a [Vec<CutSource>],
span: &[[f64; 2]],
) -> Option<&'a [CutSource]> {
item.spans
.iter()
.zip(sources)
.find(|(own, from)| own.as_slice() == span && from.len() == span.len())
.map(|(_, from)| from.as_slice())
}
fn paint_of<'a>(view: &'a View, w: &WellItem) -> Option<(&'a Paint, String)> {
if view.well_colouring != WellColouring::Scene {
return None;
}
std::iter::once(w.object.key())
.chain(w.merged.iter().map(|o| o.key()))
.find_map(|key| {
view.paints
.iter()
.find(|(k, _)| *k == key)
.map(|(_, paint)| (paint, key))
})
}
fn paint_refusal(w: &WellItem, why: &str) -> String {
format!(
"scene colours refused for {} - {why}; the well is drawn in its own colour",
well_name(&w.object)
)
}
fn md_at_px(path_px: &[[f64; 2]], md: &[f64], p: [f64; 2]) -> f64 {
let mut best = (f64::INFINITY, md.first().copied().unwrap_or(0.0));
for (i, pair) in path_px.windows(2).enumerate() {
let (a, b) = (pair[0], pair[1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 {
(((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0)
} else {
0.0
};
let (ex, ey) = (a[0] + dx * t - p[0], a[1] + dy * t - p[1]);
let d2 = ex * ex + ey * ey;
if d2 < best.0 {
best = (d2, md[i] + (md[i + 1] - md[i]) * t);
}
}
best.1
}
fn laid_well(
w: &WellItem,
view: &View,
unstratified: &UnstratifiedStyle,
map: &Map,
unit: &str,
warnings: &mut Vec<String>,
) -> LaidObject {
let key = w.object.key();
let colour = well_colour(w, view);
let highlighted = view.highlight.contains(&key);
let width = width_of(view, &key, view.well_width_px.max(1.0));
let path = map.points(&w.path);
let mut shapes = Vec::new();
if highlighted {
shapes.push(Shape::Polyline {
points: path.clone(),
stroke: "#ffb300".into(),
width: width + 5.0,
dash: false,
});
}
let painted =
paint_of(view, w).filter(|(paint, _)| crate::paint::check(w.path.len(), paint, &view.joints).is_ok());
if let Some((paint, _)) = painted {
let path_px: Vec<[f64; 2]> = w.path.iter().map(|&p| map.px(p)).collect();
match crate::paint::well_pieces(&path_px, paint, &view.joints) {
Ok(pieces) => {
shapes.push(Shape::Circle {
c: map.px(w.head),
r: (width / 2.0 + 1.5).max(2.5),
fill: colour.clone(),
});
let md = w.md.as_ref().filter(|md| md.len() == w.path.len());
let intervals = pieces
.into_iter()
.map(|piece| {
let (from, to) = match (md, piece.points.first(), piece.points.last()) {
(Some(md), Some(&a), Some(&b)) => {
(md_at_px(&path_px, md, a), md_at_px(&path_px, md, b))
}
_ => (0.0, 0.0),
};
let stroke = crate::paint::over(&piece.colour, piece.opacity, &view.background);
LaidInterval {
from,
to,
unit: String::new(),
unit_uri: None,
colour: stroke,
width,
points: piece.points,
tick: None,
}
})
.collect();
return LaidObject {
attr: Attr::Key(key),
kind: "well".into(),
title: w.object.title.clone(),
highlighted,
at: Some(w.head),
shapes,
intervals,
};
}
Err(why) => warnings.push(paint_refusal(w, &why)),
}
}
if view.well_colouring != WellColouring::Strata || unstratified.is_default() {
shapes.push(Shape::Polyline {
points: path,
stroke: colour.clone(),
width,
dash: false,
});
} else {
shapes.extend(unstratified_shapes(w, map, unstratified, width));
}
shapes.push(Shape::Circle {
c: map.px(w.head),
r: (width / 2.0 + 1.5).max(2.5),
fill: colour.clone(),
});
let mut intervals = Vec::new();
let mut last_to: Option<f64> = None;
if let Some(md) = w.md.as_ref().filter(|md| md.len() == w.path.len()) {
for iv in &w.intervals {
let pts = sub_path(&w.path, md, iv.from, iv.to);
let points = map.points(&pts);
if points.len() < 2 {
continue;
}
let drawn = interval_colour(iv, &w.object.store, &colour, view);
let tick = if view.strata_ticks && view.well_colouring == WellColouring::Strata {
let adjacent = last_to.is_some_and(|before| (before - iv.from).abs() < 1e-9);
if adjacent {
tick_across(&points, width)
} else {
None
}
} else {
None
};
last_to = Some(iv.to);
intervals.push(LaidInterval {
from: iv.from,
to: iv.to,
unit: iv.unit.clone(),
unit_uri: iv.unit_uri.clone(),
colour: drawn,
width,
points,
tick,
});
}
}
let _ = unit;
LaidObject {
attr: Attr::Key(key),
kind: "well".into(),
title: w.object.title.clone(),
highlighted,
at: Some(w.head),
shapes,
intervals,
}
}
fn effective_style(view: &View, warnings: &mut Vec<String>) -> UnstratifiedStyle {
let style = view.unstratified_style();
match style.refusal() {
None => style,
Some(why) => {
warnings.push(format!(
"unstratified style not drawn: {why}; the default is drawn"
));
UnstratifiedStyle::default()
}
}
}
pub const HATCH_ID: &str = "esv-unstratified-hatch";
pub fn hatch_ref() -> String {
format!("url(#{HATCH_ID})")
}
pub fn unstratified_spans(w: &WellItem) -> Option<Vec<(f64, f64)>> {
let md =
w.md.as_ref()
.filter(|md| md.len() == w.path.len() && md.len() > 1)?;
let (lo, hi) = (md[0], md[md.len() - 1]);
let mut covered: Vec<(f64, f64)> = w
.intervals
.iter()
.map(|iv| (iv.from.max(lo), iv.to.min(hi)))
.filter(|(a, b)| b > a)
.collect();
covered.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out = Vec::new();
let mut reached = lo;
for (a, b) in covered {
if a > reached + 1e-9 {
out.push((reached, a));
}
reached = reached.max(b);
}
if reached < hi - 1e-9 {
out.push((reached, hi));
}
Some(out)
}
fn unstratified_shapes(w: &WellItem, map: &Map, style: &UnstratifiedStyle, width: f64) -> Vec<Shape> {
let pieces: Vec<Vec<[f64; 2]>> = match (unstratified_spans(w), w.md.as_ref()) {
(Some(spans), Some(md)) => spans
.into_iter()
.map(|(a, b)| map.points(&sub_path(&w.path, md, a, b)))
.collect(),
_ => vec![map.points(&w.path)],
};
pieces
.into_iter()
.filter(|points| points.len() >= 2)
.filter_map(|points| match style {
UnstratifiedStyle::Hidden => None,
UnstratifiedStyle::Solid { colour } => Some(Shape::Polyline {
points,
stroke: colour.clone(),
width,
dash: false,
}),
UnstratifiedStyle::Dashed { colour, dash, gap } => Some(Shape::Dashed {
points,
stroke: colour.clone(),
width,
dash: *dash,
gap: *gap,
}),
UnstratifiedStyle::Hatched { .. } => Some(Shape::Polyline {
points,
stroke: hatch_ref(),
width,
dash: false,
}),
})
.collect()
}
const TICK_OVERHANG_PX: f64 = 2.0;
fn tick_across(points: &[[f64; 2]], width: f64) -> Option<[[f64; 2]; 2]> {
let (a, b) = (points.first()?, points.get(1)?);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let length = dx.hypot(dy);
if !length.is_finite() || length <= 0.0 {
return None;
}
let half = width / 2.0 + TICK_OVERHANG_PX;
let (nx, ny) = (-dy / length * half, dx / length * half);
Some([[a[0] - nx, a[1] - ny], [a[0] + nx, a[1] + ny]])
}
fn own_colour(iv: &Interval, store: &str, view: &View) -> Option<String> {
let told = iv.unit_uri.as_deref().and_then(|uri| {
let key = crate::model::object_key(store, uri);
view.unit_colours
.iter()
.find(|(k, _)| *k == key)
.map(|(_, colour)| colour.clone())
});
told.or_else(|| iv.colour.clone())
}
fn interval_colour(iv: &Interval, store: &str, well_colour: &str, view: &View) -> String {
match view.well_colouring {
WellColouring::Strata => own_colour(iv, store, view).unwrap_or_else(|| well_colour.to_string()),
_ => well_colour.to_string(),
}
}
fn sub_path(path: &[[f64; 2]], md: &[f64], from: f64, to: f64) -> Vec<[f64; 2]> {
let mut out: Vec<[f64; 2]> = Vec::new();
for i in 0..path.len().saturating_sub(1) {
let (m0, m1) = (md[i], md[i + 1]);
let (lo, hi) = (from.max(m0), to.min(m1));
if hi < lo || m1 <= m0 {
continue;
}
let at = |m: f64| {
let t = (m - m0) / (m1 - m0);
[
path[i][0] + (path[i + 1][0] - path[i][0]) * t,
path[i][1] + (path[i + 1][1] - path[i][1]) * t,
]
};
let a = at(lo);
if out.last() != Some(&a) {
out.push(a);
}
out.push(at(hi));
}
out
}
fn well_name(object: &crate::model::ObjectId) -> String {
if object.title.is_empty() {
object.uuid.chars().take(8).collect()
} else {
object.title.clone()
}
}
fn side_letter(side: Side) -> &'static str {
match side {
Side::Left => "L",
Side::Right => "R",
}
}
fn well_labels(
wells: &[&WellItem],
view: &View,
map: &Map,
unit: &str,
coded: Option<&HashMap<String, String>>,
obstacles: &[labels::Rect2],
) -> (Vec<LaidObject>, usize) {
if !(view.show.names || view.show.distances) {
return (Vec::new(), 0);
}
let mut cands = Vec::new();
for w in wells {
let head = map.px(w.head);
if head[0] < map.frame.x || head[0] > map.frame.x + map.frame.w {
continue;
}
let mut lines = Vec::new();
if view.show.names {
lines.push(labels::fit(
&shown_name(coded, &w.object),
FONT_PX,
labels::MAX_WIDTH_PX,
));
}
if view.show.distances {
let space = if unit.is_empty() { "" } else { " " };
lines.push(format!("{:.0}{space}{unit} {}", w.d, side_letter(w.side)));
}
let floor = if head[1] < map.frame.y + map.frame.h * 0.2 {
map.frame.y - 2.0
} else {
head[1] - 4.0
};
cands.push(HeadLabel {
key: w.object.key(),
title: w.object.title.clone(),
head,
lines,
floor,
});
}
let bounds = labels::Rect2 {
x0: 2.0,
y0: TITLE_BAND_PX,
x1: map.frame.x + map.frame.w,
y1: map.frame.y + map.frame.h,
};
head_labels(cands, bounds, obstacles)
}
fn plan(catalogue: &Catalogue, view: &View) -> Layout {
let p: &Plan = &catalogue.plan;
let mut warnings = common_warnings(catalogue);
warnings.extend(width_warnings(view));
let unit = unit_of(catalogue);
if view.axis != AxisMode::Projected {
warnings.push("the plan is drawn in local plan coordinates whatever the axis mode".to_string());
}
let wells: Vec<_> = p
.wells
.iter()
.filter(|w| !is_hidden(view, &w.object.key()))
.collect();
let mut e = Extent::default();
for (_, t) in &p.sections {
e.add_all(t);
}
if view.show.bands {
for (_, b) in &p.bands {
e.add_all(b);
}
}
for w in &wells {
if w.rejected.is_none() || view.show.rejected {
e.add(w.head);
}
for (_, f) in &w.feet {
e.add(*f);
}
}
let given = view.window;
let everything = Window {
x: padded(e.x, 0.05),
y: padded(e.y, 0.05),
};
let wanted = given.unwrap_or(everything);
if view.strata_legend {
warnings.push("strata legend not drawn: the plan shows no strata along its wells".to_string());
}
if !view.markers.is_empty() {
warnings.push(format!(
"{} marker(s) not drawn: markers are drawn in sections only",
view.markers.len()
));
}
let labelled = view.show.names;
let reserved = if view.show.notes {
warnings.len() + usize::from(labelled && !wells.is_empty())
} else {
0
};
let coded = (view.labels == LabelMode::Codes).then(|| codes(catalogue));
let mut entries: Vec<LegendEntry> = Vec::new();
if let Some(coded) = &coded {
for w in &wells {
if w.rejected.is_some() && !view.show.rejected {
continue;
}
if let Some(code) = coded.get(&w.object.key()) {
entries.push(LegendEntry {
code: code.clone(),
title: well_name(&w.object),
colour: NEUTRAL_COLOUR.into(),
round: true,
});
}
}
}
let legend_w = legend_width(&entries);
let (_, _, note_lines) = budget(view.size_px[1], 1, reserved, |_| PLAN_TOP);
let frame = frame_of(
view,
note_lines,
if legend_w > 0.0 { legend_w + 12.0 } else { 0.0 },
0.0,
PLAN_TOP,
);
let equal = |w: Window| {
let scale = (frame.w / (w.x[1] - w.x[0])).min(frame.h / (w.y[1] - w.y[0]));
let (cx, cy) = ((w.x[0] + w.x[1]) / 2.0, (w.y[0] + w.y[1]) / 2.0);
(
scale,
Window {
x: [cx - frame.w / scale / 2.0, cx + frame.w / scale / 2.0],
y: [cy - frame.h / scale / 2.0, cy + frame.h / scale / 2.0],
},
)
};
let (scale, window) = equal(wanted);
let whole = if given.is_some() {
equal(everything).1
} else {
window
};
let map = Map {
window,
frame,
sx: scale,
sy: scale,
};
let mut layers = Vec::new();
let mut section_objects = Vec::new();
if view.show.bands {
for (k, band) in &p.bands {
section_objects.push(LaidObject {
attr: Attr::Section(key_text(*k)),
kind: "band".into(),
title: String::new(),
highlighted: false,
at: None,
shapes: vec![Shape::Polygon {
points: map.points(band),
fill: "#90a4ae".into(),
opacity: 0.15,
stroke: Some("#90a4ae".into()),
dash: true,
}],
intervals: Vec::new(),
});
}
}
layers.push(Layer {
name: "fills",
clipped: true,
objects: section_objects,
});
let mut lines = Vec::new();
let mut directions = Vec::new();
for (k, t) in &p.sections {
if view.show.direction {
let colour = if *k == SectionKey::Long { INK } else { MARK_COLOUR };
let shapes = direction_marks(&map.points(t), colour);
if !shapes.is_empty() {
directions.push(LaidObject {
attr: Attr::Decor(format!("direction-{}", key_text(*k))),
kind: "direction".into(),
title: String::new(),
highlighted: false,
at: None,
shapes,
intervals: Vec::new(),
});
}
}
lines.push(LaidObject {
attr: Attr::Section(key_text(*k)),
kind: "section".into(),
title: String::new(),
highlighted: false,
at: None,
shapes: vec![Shape::Polyline {
points: map.points(t),
stroke: if *k == SectionKey::Long { INK } else { MARK_COLOUR }.into(),
width: if *k == SectionKey::Long { 2.0 } else { 1.2 },
dash: false,
}],
intervals: Vec::new(),
});
}
lines.extend(directions);
layers.push(Layer {
name: "traces",
clipped: true,
objects: lines,
});
let mut well_objects = Vec::new();
for w in &wells {
let key = w.object.key();
let out = w.rejected.is_some();
if out && !view.show.rejected {
continue;
}
let colour = if out {
"#9e9e9e".to_string()
} else {
colour_of(view, &key).unwrap_or_else(|| NEUTRAL_COLOUR.into())
};
let head = map.px(w.head);
let mut shapes = Vec::new();
for (_, foot) in &w.feet {
shapes.push(Shape::Polyline {
points: vec![head, map.px(*foot)],
stroke: colour.clone(),
width: width_of(view, &key, 1.0),
dash: true,
});
}
shapes.push(Shape::Circle {
c: head,
r: 3.5,
fill: colour,
});
well_objects.push(LaidObject {
highlighted: view.highlight.contains(&key),
attr: Attr::Key(key),
kind: if out { "well-rejected" } else { "well" }.into(),
title: w.object.title.clone(),
at: Some(w.head),
shapes,
intervals: Vec::new(),
});
}
layers.push(Layer {
name: "wells",
clipped: true,
objects: well_objects,
});
layers.push(Layer {
name: "marks",
clipped: true,
objects: Vec::new(),
});
let mut label_objects = Vec::new();
let mut folded = 0;
if view.show.names {
let mut cands = Vec::new();
for w in &wells {
if w.rejected.is_some() && !view.show.rejected {
continue;
}
let head = map.px(w.head);
if head[0] < frame.x
|| head[0] > frame.x + frame.w
|| head[1] < frame.y
|| head[1] > frame.y + frame.h
{
continue;
}
cands.push(HeadLabel {
key: w.object.key(),
title: w.object.title.clone(),
head,
lines: vec![labels::fit(
&shown_name(coded.as_ref(), &w.object),
FONT_PX,
labels::MAX_WIDTH_PX,
)],
floor: head[1] - 4.0,
});
}
let heads: Vec<labels::Rect2> = cands
.iter()
.map(|c| labels::Rect2 {
x0: c.head[0] - 4.0,
y0: c.head[1] - 4.0,
x1: c.head[0] + 4.0,
y1: c.head[1] + 4.0,
})
.collect();
let bounds = labels::Rect2 {
x0: frame.x,
y0: frame.y,
x1: frame.x + frame.w,
y1: frame.y + frame.h,
};
(label_objects, folded) = head_labels(cands, bounds, &heads);
}
layers.push(Layer {
name: "labels",
clipped: false,
objects: label_objects,
});
let legend_box = legend(
entries,
frame.x + frame.w + 12.0,
legend_w,
frame.y,
frame.y + frame.h,
);
layers.push(Layer {
name: "legend",
clipped: false,
objects: legend_box.into_iter().collect(),
});
if folded > 0 {
warnings.push(format!("{folded} well labels folded - they did not fit"));
}
let (xs, xv) = ticks::values(window.x[0], window.x[1], frame.w / 90.0);
let x_axis = Axis {
title: format!(
"x, local{}",
if unit.is_empty() {
String::new()
} else {
format!(" ({unit})")
}
),
mode: AxisMode::Projected,
ticks: xv
.into_iter()
.map(|v| Tick {
value: v,
at: map.px([v, 0.0])[0],
label: ticks::label(v, xs),
sublabel: None,
})
.collect(),
};
let y_axis = y_axis(&map, &unit, "y, local");
let (origin_text, origin_x) = origin_line(
view.show.origin,
Some(catalogue.plan.origin),
catalogue
.inputs
.system
.as_ref()
.or(catalogue.plan.system.as_ref()),
"Plan",
"scale 1:1 on both axes",
&frame,
&mut warnings,
);
Layout {
target: view.target,
title: "Plan".to_string(),
size: view.size_px,
background: view.background.clone(),
frame,
window,
whole,
scale: [scale, scale],
exaggeration: None,
exaggeration_text: "scale 1:1 on both axes".to_string(),
origin_text,
origin_x,
x_axis,
y_axis,
layers,
warnings,
folded_labels: folded,
note_lines,
metadata: metadata(catalogue, view),
left_shift: 0.0,
}
}
const HEAD_LEVELS: usize = 5;
const HEAD_BAND_LEVELS: usize = 3;
const TITLE_BAND_PX: f64 = 22.0;
const NAME_SHIFTS: usize = 6;
const LEGEND_MIN_PX: f64 = 120.0;
const LEGEND_MAX_PX: f64 = 280.0;
fn codes(catalogue: &Catalogue) -> HashMap<String, String> {
let sections: Vec<&Section> = std::iter::once(&catalogue.long)
.chain(&catalogue.crosses)
.collect();
let mut wells: Vec<(String, usize, f64)> = Vec::new();
let mut surfaces: Vec<(String, char, f64)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for (rank, sec) in sections.iter().enumerate() {
for item in &sec.items {
match item {
Item::Well(w) => {
if seen.insert(w.object.key()) {
wells.push((w.object.key(), rank, w.s));
}
}
Item::Trace(t) => {
if seen.insert(t.object.key()) {
let (family, value) = match t.role {
TraceRole::Fault => ('F', mean_of(&t.spans, 0)),
TraceRole::Horizon => ('H', -mean_of(&t.spans, 1)),
TraceRole::Other => ('S', -mean_of(&t.spans, 1)),
};
surfaces.push((t.object.key(), family, rank as f64 * 1e12 + value));
}
}
Item::Envelope(e) => {
if seen.insert(e.object.key()) {
surfaces.push((e.object.key(), 'G', rank as f64 * 1e12 - mean_of(&e.top, 1)));
}
}
}
}
}
for w in &catalogue.plan.wells {
let key = w.object.key();
if seen.insert(key.clone()) {
wells.push((key, usize::MAX, 0.0));
}
}
wells.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.total_cmp(&b.2)));
let mut out: HashMap<String, String> = HashMap::new();
for (n, (key, _, _)) in wells.into_iter().enumerate() {
out.insert(key, format!("W{n}"));
}
for family in ['H', 'F', 'S', 'G'] {
let mut these: Vec<&(String, char, f64)> = surfaces.iter().filter(|s| s.1 == family).collect();
these.sort_by(|a, b| a.2.total_cmp(&b.2));
for (n, (key, _, _)) in these.into_iter().enumerate() {
out.insert(key.clone(), format!("{family}{n}"));
}
}
out
}
fn mean_of(spans: &[Vec<[f64; 2]>], axis: usize) -> f64 {
let (sum, n) = spans
.iter()
.flatten()
.fold((0.0, 0usize), |(sum, n), p| (sum + p[axis], n + 1));
if n == 0 { 0.0 } else { sum / n as f64 }
}
fn shown_name(codes: Option<&HashMap<String, String>>, object: &crate::model::ObjectId) -> String {
codes
.and_then(|c| c.get(&object.key()).cloned())
.unwrap_or_else(|| well_name(object))
}
struct HeadLabel {
key: String,
title: String,
head: [f64; 2],
lines: Vec<String>,
floor: f64,
}
fn head_labels(
mut cands: Vec<HeadLabel>,
bounds: labels::Rect2,
obstacles: &[labels::Rect2],
) -> (Vec<LaidObject>, usize) {
cands.sort_by(|a, b| a.head[0].total_cmp(&b.head[0]));
let mut placer = labels::Placer::new(bounds);
obstacles.iter().for_each(|r| placer.block(*r));
let mut out = Vec::new();
let mut folded = 0;
for c in cands {
let n = c.lines.len().max(1) as f64;
let w = c
.lines
.iter()
.map(|l| labels::width_px(l, FONT_PX))
.fold(0.0, f64::max);
let h = (n - 1.0) * LINE_PX + FONT_PX + 3.0;
let mut spots: Vec<(labels::Rect2, f64, f64)> = Vec::new(); for dx in [0.0, w / 2.0 + 8.0, -(w / 2.0 + 8.0), w + 8.0, -(w + 8.0)] {
for level in 0..HEAD_LEVELS {
let baseline = c.head[1] - 7.0 - level as f64 * (h + 3.0);
let cx = c.head[0] + dx;
let rect = labels::Rect2 {
x0: cx - w / 2.0,
y0: baseline + 3.0 - h,
x1: cx + w / 2.0,
y1: baseline + 3.0,
};
if rect.y1 <= c.floor {
spots.push((rect, cx, baseline));
}
}
}
let rects: Vec<labels::Rect2> = spots.iter().map(|s| s.0).collect();
let Some(at) = placer.place(&rects) else {
folded += 1;
continue;
};
let (rect, cx, baseline) = spots[at];
let mut shapes = Vec::new();
if at > 0 {
shapes.push(Shape::Polyline {
points: vec![[cx, rect.y1], [c.head[0], c.head[1] - 4.0]],
stroke: "#888888".into(),
width: 0.7,
dash: true,
});
}
let lines = c.lines.len();
for (j, line) in c.lines.iter().enumerate() {
shapes.push(Shape::Text {
at: [cx, baseline - (lines - 1 - j) as f64 * LINE_PX],
text: line.clone(),
anchor: Anchor::Middle,
size: FONT_PX,
fill: INK.into(),
});
}
out.push(LaidObject {
attr: Attr::LabelOf(c.key),
kind: "label".into(),
title: c.title,
highlighted: false,
at: None,
shapes,
intervals: Vec::new(),
});
}
(out, folded)
}
fn text_rects<'a>(shapes: impl Iterator<Item = &'a Shape>) -> Vec<labels::Rect2> {
shapes
.filter_map(|shape| match shape {
Shape::Text {
at,
text,
anchor,
size,
..
} => {
let w = labels::width_px(text, *size);
let x0 = match anchor {
Anchor::Start => at[0],
Anchor::Middle => at[0] - w / 2.0,
Anchor::End => at[0] - w,
};
Some(labels::Rect2 {
x0,
y0: at[1] - size,
x1: x0 + w,
y1: at[1] + 2.0,
})
}
_ => None,
})
.collect()
}
struct LineName {
owner: usize,
end: [f64; 2],
text: String,
colour: String,
}
fn line_names(
mut names: Vec<LineName>,
bounds: labels::Rect2,
obstacles: &[labels::Rect2],
) -> (Vec<(usize, Shape)>, usize) {
names.sort_by(|a, b| a.end[1].total_cmp(&b.end[1]));
let mut placer = labels::Placer::new(bounds);
obstacles.iter().for_each(|r| placer.block(*r));
let mut out = Vec::new();
let mut folded = 0;
for n in names {
let w = labels::width_px(&n.text, SMALL_PX);
let mut spots: Vec<(labels::Rect2, [f64; 2])> = Vec::new(); for k in 0..NAME_SHIFTS {
let right = n.end[0] - 3.0 - k as f64 * (w + 10.0);
let above = n.end[1] - 3.0;
let below = n.end[1] + SMALL_PX + 2.0;
for baseline in [above, below] {
let rect = labels::Rect2 {
x0: right - w,
y0: baseline - SMALL_PX,
x1: right,
y1: baseline + 2.0,
};
spots.push((rect, [right, baseline]));
}
}
let rects: Vec<labels::Rect2> = spots.iter().map(|s| s.0).collect();
match placer.place(&rects) {
Some(at) => out.push((
n.owner,
Shape::Text {
at: spots[at].1,
text: n.text,
anchor: Anchor::End,
size: SMALL_PX,
fill: n.colour,
},
)),
None => folded += 1,
}
}
(out, folded)
}
pub(crate) const STRATA_LAYER: &str = "strata-legend";
pub(crate) const MARKERS_LAYER: &str = "markers-legend";
const STRATA_LEFT_PX: f64 = 4.0;
const STRATA_GAP_PX: f64 = 8.0;
#[derive(Clone, Debug, PartialEq, Eq)]
struct StrataEntry {
unit: String,
colour: String,
unit_uri: Option<String>,
stores: Vec<String>,
}
impl StrataEntry {
fn same_line(&self, unit: &str, colour: &str, unit_uri: Option<&str>) -> bool {
self.unit == unit && self.colour == colour && self.unit_uri.as_deref() == unit_uri
}
fn same_unit(&self, other: &StrataEntry) -> bool {
match (&self.unit_uri, &other.unit_uri) {
(Some(a), Some(b)) => a == b,
(None, None) => self.unit == other.unit,
_ => false,
}
}
}
fn strata_titles(entries: &[StrataEntry], view: &View) -> Vec<String> {
let name = |store: &str| {
view.store_names
.iter()
.find(|(id, _)| id == store)
.map(|(_, name)| name.clone())
.unwrap_or_else(|| store.to_string())
};
entries
.iter()
.enumerate()
.map(|(i, e)| {
let title = unit_title(&e.unit);
let twice = entries
.iter()
.enumerate()
.any(|(j, other)| j != i && other.same_unit(e));
if twice && !e.stores.is_empty() {
format!(
"{title} - {}",
e.stores.iter().map(|s| name(s)).collect::<Vec<_>>().join(", ")
)
} else {
title
}
})
.collect()
}
fn painted(view: &View, w: &WellItem) -> bool {
paint_of(view, w).is_some_and(|(paint, _)| crate::paint::check(w.path.len(), paint, &view.joints).is_ok())
}
fn strata_units(wells: &[&WellItem], view: &View) -> Vec<StrataEntry> {
if view.well_colouring != WellColouring::Strata {
return Vec::new();
}
let mut nodes: Vec<StrataEntry> = Vec::new();
let mut edges: Vec<(usize, usize)> = Vec::new();
for w in wells {
if painted(view, w) || w.md.as_ref().is_none_or(|md| md.len() != w.path.len()) {
continue;
}
let mut intervals: Vec<&Interval> = w
.intervals
.iter()
.filter(|iv| own_colour(iv, &w.object.store, view).is_some())
.collect();
intervals.sort_by(|a, b| a.from.total_cmp(&b.from));
let mut previous: Option<usize> = None;
for iv in intervals {
let colour = interval_colour(iv, &w.object.store, "", view);
let at = match nodes
.iter()
.position(|n| n.same_line(&iv.unit, &colour, iv.unit_uri.as_deref()))
{
Some(at) => {
if !nodes[at].stores.contains(&w.object.store) {
nodes[at].stores.push(w.object.store.clone());
}
at
}
None => {
nodes.push(StrataEntry {
unit: iv.unit.clone(),
colour,
unit_uri: iv.unit_uri.clone(),
stores: vec![w.object.store.clone()],
});
nodes.len() - 1
}
};
if let Some(before) = previous.filter(|&p| p != at) {
edges.push((before, at));
}
previous = Some(at);
}
}
let mut waiting: Vec<usize> = vec![0; nodes.len()];
for &(_, to) in &edges {
waiting[to] += 1;
}
let mut done = vec![false; nodes.len()];
let mut order = Vec::with_capacity(nodes.len());
while order.len() < nodes.len() {
let next = (0..nodes.len())
.find(|&i| !done[i] && waiting[i] == 0)
.or_else(|| (0..nodes.len()).find(|&i| !done[i]));
let Some(next) = next else { break };
done[next] = true;
order.push(nodes[next].clone());
for &(from, to) in &edges {
if from == next && waiting[to] > 0 {
waiting[to] -= 1;
}
}
}
order
}
fn has_unstratified(w: &WellItem) -> bool {
unstratified_spans(w).is_none_or(|spans| !spans.is_empty())
}
fn strata_refusal(wells: &[&WellItem], view: &View) -> String {
if view.well_colouring != WellColouring::Strata {
return "strata legend not drawn: the wells are not coloured by strata".to_string();
}
if wells.is_empty() {
return "strata legend not drawn: no well is drawn".to_string();
}
let by_scene = wells.iter().filter(|w| painted(view, w)).count();
if by_scene > 0 {
format!(
"strata legend not drawn: no unit with a colour of its own on the wells drawn \
({by_scene} well(s) are painted by the scene, whose colours carry no unit)"
)
} else {
"strata legend not drawn: no well shows a stratigraphic unit with a colour of its own".to_string()
}
}
fn strata_width(entries: &[StrataEntry], no_strata: bool, view: &View) -> f64 {
if entries.is_empty() {
return 0.0;
}
let widest = strata_titles(entries, view)
.iter()
.map(|title| labels::width_px(title, SMALL_PX))
.chain(no_strata.then(|| labels::width_px(NO_STRATA_TITLE, SMALL_PX)))
.fold(labels::width_px("Stratigraphy", FONT_PX), f64::max);
(widest + 36.0).clamp(LEGEND_MIN_PX, LEGEND_MAX_PX)
}
const NO_STRATA_TITLE: &str = "No strata";
fn unit_title(unit: &str) -> String {
if unit.is_empty() {
"(unnamed unit)".to_string()
} else {
unit.to_string()
}
}
fn strata_legend(
entries: &[StrataEntry],
view: &View,
no_strata: Option<&UnstratifiedStyle>,
x: f64,
width: f64,
top: f64,
bottom: f64,
) -> Vec<LaidObject> {
if entries.is_empty() {
return Vec::new();
}
let room = (((bottom - top) - LINE_PX - 8.0) / LINE_PX).floor().max(1.0) as usize;
let extra = usize::from(no_strata.is_some());
let shown = if entries.len() + extra > room {
room.saturating_sub(1 + extra)
} else {
entries.len()
};
let height = LINE_PX * (shown + 1 + extra + usize::from(shown < entries.len())) as f64 + 10.0;
let decor = |name: &str, title: String, shapes: Vec<Shape>| LaidObject {
attr: Attr::Decor(name.into()),
kind: name.into(),
title,
highlighted: false,
at: None,
shapes,
intervals: Vec::new(),
};
let mut frame = vec![
Shape::Polygon {
points: vec![
[x, top],
[x + width, top],
[x + width, top + height],
[x, top + height],
],
fill: "#ffffff".into(),
opacity: 0.92,
stroke: Some("#999999".into()),
dash: false,
},
Shape::Text {
at: [x + 8.0, top + LINE_PX],
text: "Stratigraphy".into(),
anchor: Anchor::Start,
size: FONT_PX,
fill: INK.into(),
},
];
if shown < entries.len() {
frame.push(Shape::Text {
at: [x + 8.0, top + LINE_PX * ((shown + extra) as f64 + 2.0)],
text: format!("+ {} more units", entries.len() - shown),
anchor: Anchor::Start,
size: SMALL_PX,
fill: INK.into(),
});
}
let mut out = vec![decor("strata-box", String::new(), frame)];
let titles = strata_titles(entries, view);
for (i, e) in entries.iter().take(shown).enumerate() {
let y = top + LINE_PX * (i as f64 + 2.0);
let title = titles[i].clone();
let mut entry = decor(
"strata-entry",
e.unit.clone(),
vec![
Shape::Polygon {
points: vec![
[x + 8.0, y - 9.0],
[x + 22.0, y - 9.0],
[x + 22.0, y - 1.0],
[x + 8.0, y - 1.0],
],
fill: e.colour.clone(),
opacity: 1.0,
stroke: Some("#666666".into()),
dash: false,
},
Shape::Text {
at: [x + 28.0, y],
text: labels::fit(&title, SMALL_PX, (width - 28.0 - 6.0).max(20.0)),
anchor: Anchor::Start,
size: SMALL_PX,
fill: INK.into(),
},
],
);
if let Some(uri) = &e.unit_uri {
entry.attr = Attr::UnitEntry("strata-entry".into(), uri.clone(), e.stores.clone());
}
out.push(entry);
}
if let Some(style) = no_strata {
let y = top + LINE_PX * (shown as f64 + 2.0);
let swatch = [
[x + 8.0, y - 9.0],
[x + 22.0, y - 9.0],
[x + 22.0, y - 1.0],
[x + 8.0, y - 1.0],
];
let boxed = |fill: String| Shape::Polygon {
points: swatch.to_vec(),
fill,
opacity: 1.0,
stroke: Some("#666666".into()),
dash: false,
};
let mut shapes = match style {
UnstratifiedStyle::Solid { colour } => vec![boxed(colour.clone())],
UnstratifiedStyle::Dashed { colour, dash, gap } => vec![Shape::Dashed {
points: vec![[x + 6.0, y - 5.0], [x + 24.0, y - 5.0]],
stroke: colour.clone(),
width: 3.0,
dash: *dash,
gap: *gap,
}],
UnstratifiedStyle::Hatched { .. } => vec![boxed(hatch_ref())],
UnstratifiedStyle::Hidden => Vec::new(),
};
shapes.push(Shape::Text {
at: [x + 28.0, y],
text: labels::fit(NO_STRATA_TITLE, SMALL_PX, (width - 28.0 - 6.0).max(20.0)),
anchor: Anchor::Start,
size: SMALL_PX,
fill: INK.into(),
});
out.push(decor("strata-none", String::new(), shapes));
}
out
}
struct LegendEntry {
code: String,
title: String,
colour: String,
round: bool,
}
fn legend_width(entries: &[LegendEntry]) -> f64 {
if entries.is_empty() {
return 0.0;
}
let widest = entries
.iter()
.map(|e| labels::width_px(&e.code, SMALL_PX) + 6.0 + labels::width_px(&e.title, SMALL_PX))
.fold(0.0, f64::max);
(widest + 30.0).clamp(LEGEND_MIN_PX, LEGEND_MAX_PX)
}
fn legend(mut entries: Vec<LegendEntry>, x: f64, width: f64, top: f64, bottom: f64) -> Option<LaidObject> {
if entries.is_empty() {
return None;
}
let family = |c: &str| {
["W", "H", "F", "S", "G"]
.iter()
.position(|f| c.starts_with(f))
.unwrap_or(9)
};
let number = |c: &str| c[1..].parse::<usize>().unwrap_or(0);
entries.sort_by(|a, b| {
family(&a.code)
.cmp(&family(&b.code))
.then(number(&a.code).cmp(&number(&b.code)))
});
let room = (((bottom - top) - LINE_PX - 8.0) / LINE_PX).floor().max(1.0) as usize;
let shown = if entries.len() > room {
room.saturating_sub(1)
} else {
entries.len()
};
let height = LINE_PX * (shown + 1 + usize::from(shown < entries.len())) as f64 + 10.0;
let mut shapes = vec![
Shape::Polygon {
points: vec![
[x, top],
[x + width, top],
[x + width, top + height],
[x, top + height],
],
fill: "#ffffff".into(),
opacity: 0.92,
stroke: Some("#999999".into()),
dash: false,
},
Shape::Text {
at: [x + 8.0, top + LINE_PX],
text: "Legend".into(),
anchor: Anchor::Start,
size: FONT_PX,
fill: INK.into(),
},
];
let code_w = entries
.iter()
.map(|e| labels::width_px(&e.code, SMALL_PX))
.fold(0.0, f64::max);
for (i, e) in entries.iter().take(shown).enumerate() {
let y = top + LINE_PX * (i as f64 + 2.0);
if e.round {
shapes.push(Shape::Circle {
c: [x + 12.0, y - 4.0],
r: 3.5,
fill: e.colour.clone(),
});
} else {
shapes.push(Shape::Polyline {
points: vec![[x + 6.0, y - 4.0], [x + 18.0, y - 4.0]],
stroke: e.colour.clone(),
width: 3.0,
dash: false,
});
}
shapes.push(Shape::Text {
at: [x + 24.0, y],
text: e.code.clone(),
anchor: Anchor::Start,
size: SMALL_PX,
fill: INK.into(),
});
let room = width - 24.0 - code_w - 12.0;
shapes.push(Shape::Text {
at: [x + 24.0 + code_w + 6.0, y],
text: labels::fit(&e.title, SMALL_PX, room.max(20.0)),
anchor: Anchor::Start,
size: SMALL_PX,
fill: INK.into(),
});
}
if shown < entries.len() {
shapes.push(Shape::Text {
at: [x + 8.0, top + LINE_PX * (shown as f64 + 2.0)],
text: format!("+ {} more - in the objects list", entries.len() - shown),
anchor: Anchor::Start,
size: SMALL_PX,
fill: INK.into(),
});
}
Some(LaidObject {
attr: Attr::Decor("legend".into()),
kind: "legend".into(),
title: "Legend".into(),
highlighted: false,
at: None,
shapes,
intervals: Vec::new(),
})
}