Skip to content
36 changes: 29 additions & 7 deletions crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<u32> = layout
.elements
Expand All @@ -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<String> = 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.
Expand All @@ -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
Expand Down
187 changes: 150 additions & 37 deletions crates/engine/src/cmd/layout.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/engine/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1738,7 +1738,7 @@ pub(crate) fn layout_notices(
let cut = total(|r| r.elements_cut);
if cut > 0 {
notices.push(format!(
"{name}: {cut} layout element(s) with a name, text, polygon points or legend exclusions cut to their limits {when} (in {})",
"{name}: {cut} layout element(s) with a name, text, polygon points, legend exclusions or legend labels cut to their limits {when} (in {})",
layout_names(repairs, |r| r.elements_cut)
));
}
Expand Down
181 changes: 181 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29537,6 +29537,187 @@ fn legends_leave_layers_out() {
assert!(left_out(&s, lakes).is_empty());
}

/// A legend can show a layer under a label instead of its name (#1654): set when added or later,
/// entry by entry, `null` or `{}` going back to the name; checked against its frame's map; kept
/// through a saved project and layout file (not written when empty); following a renamed layer
/// and dropped with a removed one.
#[test]
fn legends_label_layers() {
use rustgis_map::layout::ElementKind;
let mut s = Session::new(sample::world_project());
s.run("layout.new", &json!({"name": "Rótulos"})).unwrap();
let labels = |s: &Session, id: u32| -> Vec<(String, String)> {
match s.project.layouts[0].element(id).map(|e| &e.kind) {
Some(ElementKind::Legend { labels, .. }) => labels.iter().map(|(n, l)| (n.clone(), l.clone())).collect(),
_ => panic!("a legend"),
}
};
let pair = |n: &str, l: &str| (n.to_string(), l.to_string());
let plain = s.run("layout.addElement", &json!({"kind": "legend"})).unwrap()["element"].as_u64().unwrap() as u32;
assert!(labels(&s, plain).is_empty());
assert!(!serde_json::to_string(&s.project.layouts[0]).unwrap().contains("labels"), "none isn't written");
// Names as the map spells them; a blank label is none.
let id = s.run("layout.addElement", &json!({"kind": "legend", "labels": {"rivers": "Rios", "LAKES": " "}})).unwrap()["element"]
.as_u64()
.unwrap() as u32;
assert_eq!(labels(&s, id), [pair("Rivers", "Rios")]);
// Entries put in over those it has; null takes one back to the name.
s.run("layout.update", &json!({"element": id, "labels": {"Lakes": "Lagos", "Cities": "Cidades"}})).unwrap();
assert_eq!(labels(&s, id), [pair("Cities", "Cidades"), pair("Lakes", "Lagos"), pair("Rivers", "Rios")]);
s.run("layout.update", &json!({"element": id, "labels": {"cities": null, "RIVERS": "Rios principais"}})).unwrap();
assert_eq!(labels(&s, id), [pair("Lakes", "Lagos"), pair("Rivers", "Rios principais")]);
// A layer the map hasn't, a layer named twice, or not an object of labels is an error that
// changes nothing; so is `labels` on another kind of element.
for bad in [
json!({"Atlantis": "x"}),
json!({"Rivers": "a", "rivers": "b"}),
json!({"Rivers": 3}),
json!({"Rivers": ["Rios"]}),
json!(["Rivers"]),
json!("Rivers"),
] {
assert!(s.run("layout.update", &json!({"element": id, "labels": bad})).is_err(), "{bad}");
assert!(s.run("layout.addElement", &json!({"kind": "legend", "labels": bad})).is_err(), "{bad}");
}
assert!(s.run("layout.update", &json!({"element": id, "labels": {"Atlantis": "x"}})).unwrap_err().contains("no layer 'Atlantis'"));
assert_eq!(labels(&s, id), [pair("Lakes", "Lagos"), pair("Rivers", "Rios principais")]);
let title = s.project.layouts[0].elements.iter().find(|e| e.name == "Title").unwrap().id;
assert!(s.run("layout.update", &json!({"element": title, "labels": {"Rivers": "Rios"}})).unwrap_err().contains("only legends"));
assert!(s.run("layout.addElement", &json!({"kind": "scaleBar", "labels": {"Rivers": "Rios"}})).unwrap_err().contains("only legends"));
// Any other value is refused too, rather than left unread, and nothing is added.
let count = s.project.layouts[0].elements.len();
for v in [json!("Rivers"), json!(["Rivers"]), json!(true)] {
assert!(s.run("layout.update", &json!({"element": title, "labels": v})).unwrap_err().contains("only legends"), "{v}");
assert!(s.run("layout.addElement", &json!({"kind": "scaleBar", "labels": v})).unwrap_err().contains("only legends"), "{v}");
}
let err = s.run("layout.addElement", &json!({"kind": "grid", "labels": "no"})).unwrap_err();
assert!(err.contains("a grid's labels is true or false"), "{err}");
assert_eq!(s.project.layouts[0].elements.len(), count);
// A grid's own "labels" (a bool) is still its.
let frame = s.run("layout.addElement", &json!({"kind": "grid", "labels": false})).unwrap()["element"].as_u64().unwrap() as u32;
assert!(matches!(
s.project.layouts[0].element(frame).map(|e| &e.kind),
Some(ElementKind::MapFrame { grid: Some(g), .. }) if !g.labels
));
// On layout.update they go under "grid", and a top-level "labels" there says so.
let err = s.run("layout.update", &json!({"element": frame, "labels": true})).unwrap_err();
assert!(err.contains(r#"go under "grid""#), "{err}");
s.run("layout.update", &json!({"element": frame, "grid": {"labels": true}})).unwrap();
assert!(matches!(
s.project.layouts[0].element(frame).map(|e| &e.kind),
Some(ElementKind::MapFrame { grid: Some(g), .. }) if g.labels
));
// A label longer than a legend keeps is refused, not cut, and changes nothing; one at the
// limit is kept whole.
let before = labels(&s, plain);
let long = "x".repeat(rustgis_map::layout::MAX_LEGEND_LABEL_CHARS + 1);
let err = s.run("layout.update", &json!({"element": plain, "labels": {"Countries": long}})).unwrap_err();
assert!(err.contains("at most 200 characters") && err.contains("has 201"), "{err}");
assert_eq!(labels(&s, plain), before);
let full = "é".repeat(rustgis_map::layout::MAX_LEGEND_LABEL_CHARS);
s.run("layout.update", &json!({"element": plain, "labels": {"Countries": full}})).unwrap();
assert_eq!(labels(&s, plain)[0].1, full);
s.run("layout.update", &json!({"element": plain, "labels": {"Countries": null}})).unwrap();
// Kept through a saved project, and a layout file.
let dir = std::env::temp_dir().join(format!("rustgis-legend-labels-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("rotulos.rgis");
s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap();
s.run("project.open", &json!({"path": path.display().to_string()})).unwrap();
assert_eq!(labels(&s, id), [pair("Lakes", "Lagos"), pair("Rivers", "Rios principais")]);
let file = dir.join("rotulos.rgislayout");
s.run("layout.save", &json!({"path": file.display().to_string()})).unwrap();
s.run("layout.import", &json!({"path": file.display().to_string()})).unwrap();
assert!(matches!(
s.project.layouts[1].element(id).map(|e| &e.kind),
Some(ElementKind::Legend { labels, .. }) if labels.get("Rivers").is_some_and(|l| l == "Rios principais")
));
let cut_notice =
|s: &Session, from: usize| s.notices[from..].iter().filter(|n| n.text.contains("legend labels cut to their limits")).count();
assert_eq!(cut_notice(&s, 0), 0, "nothing was cut");
// A layout file with a label longer than a legend keeps is read with it cut, and says so.
let mut text: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
let legend = text["elements"].as_array_mut().unwrap().iter_mut().find(|e| e["id"] == json!(id)).unwrap();
legend["kind"]["Legend"]["labels"]["Rivers"] = json!("x".repeat(rustgis_map::layout::MAX_LEGEND_LABEL_CHARS + 50));
let mark = s.notices.len();
s.run("layout.import", &json!({"text": text.to_string()})).unwrap();
assert_eq!(cut_notice(&s, mark), 1);
assert!(matches!(
s.project.layouts[2].element(id).map(|e| &e.kind),
Some(ElementKind::Legend { labels, .. }) if labels["Rivers"].chars().count() == rustgis_map::layout::MAX_LEGEND_LABEL_CHARS
));
// One with two spellings of a layer keeps the first in order, as a command keeps one, and
// says so.
let legend = text["elements"].as_array_mut().unwrap().iter_mut().find(|e| e["id"] == json!(id)).unwrap();
legend["kind"]["Legend"]["labels"] = json!({"Rivers": "Rios", "rivers": "rios", "Lakes": "Lagos"});
let mark = s.notices.len();
s.run("layout.import", &json!({"text": text.to_string()})).unwrap();
assert_eq!(cut_notice(&s, mark), 1);
assert!(matches!(
s.project.layouts[3].element(id).map(|e| &e.kind),
Some(ElementKind::Legend { labels, .. })
if labels.iter().map(|(n, l)| (n.as_str(), l.as_str())).collect::<Vec<_>>() == [("Lakes", "Lagos"), ("Rivers", "Rios")]
));
std::fs::remove_dir_all(&dir).ok();
// (The imported layout is the active one now; the rest goes to the first.)
// The label follows its layer renamed, and goes with it removed, so a later layer of that
// name isn't labelled unasked.
let lost = |s: &Session, from: usize| s.notices[from..].iter().filter(|n| n.text.contains("lost its label")).count();
let mark = s.notices.len();
s.run("layer.rename", &json!({"layer": "Rivers", "name": "Hidrografia"})).unwrap();
assert_eq!(labels(&s, id), [pair("Hidrografia", "Rios principais"), pair("Lakes", "Lagos")]);
s.run("layer.remove", &json!({"layer": "Lakes"})).unwrap();
assert_eq!(labels(&s, id), [pair("Hidrografia", "Rios principais")]);
// Renamed onto the name of a layer the legend lists, a layer shares that one's label (here
// none) rather than giving it its own.
s.run("layer.rename", &json!({"layer": "Hidrografia", "name": "Cities"})).unwrap();
// Its label is dropped, and said once for the four legends that had it (this one and the
// three layouts imported above); following a rename or a removal is not.
assert_eq!(lost(&s, mark), 1);
assert!(s.notices[mark..].iter().any(|n| n.text.contains("'Hidrografia', renamed 'Cities', lost its label in 4 legend(s)")));
assert!(labels(&s, id).is_empty());
// A WMS or WMTS layer is labelled by name too.
let wms = json!({"url": "https://maps.example.org/wms/{bbox-epsg-3857}", "name": "Soils", "overlay": true, "legend": "https://maps.example.org/legend.png"});
s.run("map.addTiles", &wms).unwrap();
s.run("layout.update", &json!({"layout": 0, "element": id, "labels": {"soils": "Solos"}})).unwrap();
assert_eq!(labels(&s, id), [pair("Soils", "Solos")]);
s.run("map.setTiles", &json!({"name": "Soils", "rename": "Soil map"})).unwrap();
assert_eq!(labels(&s, id), [pair("Soil map", "Solos")]);
// An empty object shows every layer's name again.
s.run("layout.update", &json!({"layout": 0, "element": id, "labels": {}})).unwrap();
assert!(labels(&s, id).is_empty());
assert!(!serde_json::to_string(&s.project.layouts[0].element(id).unwrap()).unwrap().contains("labels"));
}

/// A legend given labels says when they don't fit its box, added or changed, in one column too
/// (#1654); labels that fit say nothing.
#[test]
fn a_legends_labels_say_when_they_dont_fit() {
let mut s = Session::new(sample::world_project());
s.run("layout.new", &json!({"name": "Rótulos"})).unwrap();
let said = |s: &Session, mark: u64| s.notices_since(mark).iter().map(|n| n.text.clone()).collect::<Vec<_>>().join(" | ");
let cut = "the ends of long labels are left out";
let box_ = json!({"kind": "legend", "x": 10, "y": 10, "w": 150, "h": 180});
let with = |labels: Value| {
let mut p = box_.clone();
p["labels"] = labels;
p
};
let mark = s.notice_mark();
let id = s.run("layout.addElement", &with(json!({"Rivers": "Rios"}))).unwrap()["element"].as_u64().unwrap();
assert_eq!(said(&s, mark), "");
let long = "Rios principais da América do Sul ".repeat(5);
let mark = s.notice_mark();
s.run("layout.addElement", &with(json!({"Rivers": long}))).unwrap();
assert!(said(&s, mark).contains(cut), "{}", said(&s, mark));
let mark = s.notice_mark();
s.run("layout.update", &json!({"element": id, "labels": {"Rivers": long}})).unwrap();
assert!(said(&s, mark).contains(cut), "{}", said(&s, mark));
let mark = s.notice_mark();
s.run("layout.update", &json!({"element": id, "labels": {"Rivers": null}})).unwrap();
assert_eq!(said(&s, mark), "");
}

/// A point layer of events, one a month through 2015 (two in March), with their dates as text,
/// epoch milliseconds and years.
fn timed_events() -> Session {
Expand Down
Loading
Loading