diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 9703fe076..5b7e4dd80 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -4786,12 +4786,13 @@ fn add_sample(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"layer": id})) } -/// Legends that leave out layer `old` of the active map, in the frames that show it, follow it: -/// to its new name, or (`None`, the layer deleted) by dropping it, so a later layer of that name -/// isn't left out unasked. Called once the layer is renamed or gone: while another layer still -/// bears `old` (names may repeat), the legends keep it for that one. Renamed onto the name of -/// another layer a legend lists, the layer is listed with it rather than taking that one out. -/// A WMS or WMTS layer's name counts as a layer's: legends list those too. +/// Legends that leave out or label layer `old` of the active map, in the frames that show it, +/// follow it: to its new name, or (`None`, the layer deleted) by dropping it, so a later layer of +/// that name isn't left out or labelled unasked. Called once the layer is renamed or gone: while +/// another layer still bears `old` (names may repeat), the legends keep it for that one. Renamed +/// onto the name of another layer a legend lists, the layer is listed with it rather than taking +/// that one out, and under that one's label rather than giving it its own, which is said. A WMS or +/// WMTS layer's name counts as a layer's: legends list those too. pub(crate) fn legends_follow_layer(s: &mut Session, old: &str, new: Option<&str>) { let named = |s: &Session, n: &str| { let m = s.map(); @@ -4802,6 +4803,7 @@ pub(crate) fn legends_follow_layer(s: &mut Session, old: &str, new: Option<&str> } let shared = new.is_some_and(|n| named(s, n) > 1); let map = s.map().name.clone(); + let mut unlabelled = 0usize; for layout in &mut s.project.layouts { let frames: Vec = layout .elements @@ -4810,9 +4812,21 @@ pub(crate) fn legends_follow_layer(s: &mut Session, old: &str, new: Option<&str> .map(|e| e.id) .collect(); for e in &mut layout.elements { - if let rustgis_map::layout::ElementKind::Legend { frame, exclude, .. } = &mut e.kind + if let rustgis_map::layout::ElementKind::Legend { frame, exclude, labels, .. } = &mut e.kind && frames.contains(frame) { + // Its label (#1654), taken off the old name; onto the new one unless another layer + // bears that name, whose label (or none) the renamed layer then shares. + let keys: Vec = labels.keys().filter(|n| n.eq_ignore_ascii_case(old)).cloned().collect(); + let label = keys.iter().filter_map(|k| labels.remove(k)).last(); + match (new, label, shared) { + (Some(n), Some(l), false) => { + labels.retain(|x, _| !x.eq_ignore_ascii_case(n)); + labels.insert(n.to_string(), l); + } + (Some(_), Some(_), true) => unlabelled += 1, + _ => {} + } match new { // Another layer the legend lists bears the new name: carrying the exclusion // over would take that one out too, so the renamed layer is listed with it. @@ -4836,6 +4850,14 @@ pub(crate) fn legends_follow_layer(s: &mut Session, old: &str, new: Option<&str> } } } + // The text given for the layer goes while the layer stays: said. (A removed layer's goes + // with its row.) + if let Some(n) = new.filter(|_| unlabelled > 0) { + s.notify( + format!("'{old}', renamed '{n}', lost its label in {unlabelled} legend(s): another layer is called '{n}', and a legend labels layers by name"), + false, + ); + } } /// Removes the layers `ids` from the active map, and legends that left them out forget them, as diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 54227953c..94562a964 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1,15 +1,17 @@ //! Home › Project (New Layout, Import Layout), Publish › Map Frames, Map Surrounds and Text, and //! Publish › Output (Export Layout) and Save As (Layout File): page layouts. +use std::collections::BTreeMap; use std::path::Path; use rustgis_geom::{Coord, Extent}; -use rustgis_map::Rgba; use rustgis_map::layout::{ Arrange, ArrowStyle, CoordFormat, ElementKind, FontFamily, FrameShape, Grid, GridEdges, GridKind, GridStyle, Guides, Layout, LineDir, - LineHead, MAX_COLORBAR_TICKS, MAX_GRID_WIDTH, MAX_GUIDES, MAX_LEGEND_COLUMNS, MAX_SCALE_DIVISIONS, MAX_TICK_MM, MIN_ELEMENT_MM, - MapSeries, Orientation, PAGES, ScaleBarStyle, ScaleUnits, TextAlign, TickSide, fit_scale, line_angle, line_at_angle, line_length, + LineHead, MAX_COLORBAR_TICKS, MAX_GRID_WIDTH, MAX_GUIDES, MAX_LEGEND_COLUMNS, MAX_LEGEND_LABEL_CHARS, MAX_LEGEND_LABELS, + MAX_SCALE_DIVISIONS, MAX_TICK_MM, MIN_ELEMENT_MM, MapSeries, Orientation, PAGES, ScaleBarStyle, ScaleUnits, TextAlign, TickSide, + fit_scale, line_angle, line_at_angle, line_length, }; +use rustgis_map::{Map, Rgba}; use serde_json::{Value, json}; use super::{b, f, numbered, ps}; @@ -91,10 +93,10 @@ pub fn commands() -> Vec { r#"{"layout"?, "shape"?: "rectangle"|"circle"|"ellipse"|"polygon"|"lasso", "points"?: [[u, v], …] (polygon/lasso, 0..1 across the frame), "map"?: name, "x"?, "y"?, "w"?, "h"?: mm}"#, ), C::new("layout.addElement", "Add Element", "Publish › Map Surrounds", add_element).inputs(element_inputs).roles(&[]).effects(crate::policy::Effects::NONE.with(crate::policy::Effect::Files)).params( - r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA" (text, dynamicText, line, arrow, rectangle, ellipse, circle: its outline; extentIndicator; grid; scaleBar; refused for other kinds), "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "columns"?: 1..3 (legend: the columns its rows are laid in; default 1), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow; scaleBar: the alternating bar's light divisions, default white; refused for other kinds), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool, "style"?: "lines"|"ticks"|"linesAndTicks" (grid) or "classic"|"compass"|"triangle"|"star" (northArrow) or "alternating"|"stepped"|"line" (scaleBar), "tickLength"?: mm, "format"?: "decimal"|"dms", "hemisphere"?: bool (N/S/E/W rather than a sign), "edges"?: ["top","bottom","left","right"] (labelled edges), "labelOrientation"?: "horizontal"|"vertical", "off"?: bool (grid; options not given keep the grid's), "outline"?: "#RRGGBB" (northArrow, or a rectangle's, ellipse's or circle's outline instead of color; refused for other kinds), "units"?: "auto"|"kilometers"|"meters"|"miles"|"feet", "divisions"?: 1..10 (scaleBar, whose "color" is its lines, dark divisions and labels, default #222222), "x"?, "y"?, "w"?, "h"?: mm}"##, + r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA" (text, dynamicText, line, arrow, rectangle, ellipse, circle: its outline; extentIndicator; grid; scaleBar; refused for other kinds), "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "columns"?: 1..3 (legend: the columns its rows are laid in; default 1), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow; scaleBar: the alternating bar's light divisions, default white; refused for other kinds), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool (grid) | {"": "