Repository navigation
feat(labels): callouts, show all labels, and a Move Label tool #1128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e9e06c5
aa4feb3
98ea58b
d0a4281
3e8f4f3
b76c7bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| //! layer's label visibility range and placement, and its label classes (#618). | ||
|
|
||
| use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; | ||
| use rustgis_geom::Coord; | ||
| use rustgis_map::symbology::{MAX_LABEL_CLASSES, label_class_name}; | ||
| use rustgis_map::{LabelBackground, LabelClass, LabelMatcher, LabelPlacement, Labeling, Renderer, Rgba, Symbol}; | ||
| use serde_json::{Value, json}; | ||
|
|
@@ -85,6 +86,7 @@ pub(super) fn background_change(v: &Value) -> Result<BackgroundChange, String> { | |
| })) | ||
| } | ||
|
|
||
| /// Registers undoable label styling, placement and manual moves, plus map display controls. | ||
| pub fn commands() -> Vec<C> { | ||
| vec![ | ||
| C::new("labels.lock", "Lock", "Labeling › Map Labeling", |s, p| { | ||
|
|
@@ -135,8 +137,8 @@ pub fn commands() -> Vec<C> { | |
| // A position that isn't a placement name is refused, not dropped for the others (null: | ||
| // none, as for the other keys). | ||
| None if p.get("position").is_some_and(|v| !v.is_null()) => return Err("position must be a placement name".into()), | ||
| None if ["offset", "repeat", "otherPositions"].iter().any(|k| p.get(k).is_some()) => None, | ||
| None => return Err("position is required (or an offset, a repeat or otherPositions)".into()), | ||
| None if ["offset", "repeat", "otherPositions", "showAll", "callout"].iter().any(|k| p.get(k).is_some()) => None, | ||
| None => return Err("position is required (or an offset, a repeat, otherPositions, showAll or a callout)".into()), | ||
| }; | ||
| let offset = match p.get("offset") { | ||
| None => None, | ||
|
|
@@ -152,6 +154,28 @@ pub fn commands() -> Vec<C> { | |
| _ => return Err("repeat must be a number of points, or 0 or null for none".into()), | ||
| }, | ||
| }; | ||
| // Every label shown, over the others where they crowd. | ||
| let show_all = match p.get("showAll") { | ||
| None => None, | ||
| Some(v) => Some(v.as_bool().ok_or("showAll must be true or false")?), | ||
| }; | ||
| // Callouts: on (true: its longest line as it was, or the default), on with this | ||
| // longest line (points), or off (false, 0 or null). | ||
| enum Callout { | ||
| Off, | ||
| On, | ||
| Reach(f32), | ||
| } | ||
| let callout = match p.get("callout") { | ||
| None => None, | ||
| Some(Value::Null | Value::Bool(false)) => Some(Callout::Off), | ||
| Some(Value::Bool(true)) => Some(Callout::On), | ||
| Some(v) => match number(v) { | ||
| Some(0.0) => Some(Callout::Off), | ||
| Some(r) if r > 0.0 => Some(rustgis_map::symbology::callout_reach(r).map_or(Callout::Off, Callout::Reach)), | ||
| _ => return Err("callout must be true or false, or its longest line in points".into()), | ||
| }, | ||
| }; | ||
| // A point's label tries the other places round it when its own is taken (#1011). | ||
| let others = match p.get("otherPositions") { | ||
| None => None, | ||
|
|
@@ -176,12 +200,85 @@ pub fn commands() -> Vec<C> { | |
| if let Some(r) = repeat { | ||
| c.repeat = r; | ||
| } | ||
| if let Some(a) = show_all { | ||
| c.show_all = a; | ||
| } | ||
| match callout { | ||
| Some(Callout::Off) => c.callout = None, | ||
| Some(Callout::On) => c.callout = Some(c.callout.unwrap_or(rustgis_map::symbology::DEFAULT_CALLOUT)), | ||
| Some(Callout::Reach(r)) => c.callout = Some(r), | ||
|
Comment on lines
+206
to
+209
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| None => {} | ||
| } | ||
| }) | ||
| }) | ||
| .when(need_feature_layer) | ||
| .params( | ||
| r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "position"?: "auto"|"center"|"above"|"below"|"left"|"right"|"aboveRight"|"aboveLeft"|"belowRight"|"belowLeft"|"parallel" (along a line, straight)|"curved" (along a line, following its bends) (required without offset, repeat or otherPositions), "offset"?: [right, down] points (-200 to 200 each) the label moves from where its position puts it ([0, 0]: none; a label along a line isn't moved), "repeat"?: points (10 to 10000) between a line's labels, spaced out from the one at its middle, at most 32 a line (0 or null: the one at its middle; parallel and curved only), "otherPositions"?: bool (a point's label whose place is taken tries the others round the point, corners first; point layers only: true is refused on lines and polygons)}"#, | ||
| r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "position"?: "auto"|"center"|"above"|"below"|"left"|"right"|"aboveRight"|"aboveLeft"|"belowRight"|"belowLeft"|"parallel" (along a line, straight)|"curved" (along a line, following its bends) (required without offset, repeat, otherPositions, showAll or callout), "offset"?: [right, down] points (-200 to 200 each) the label moves from where its position puts it ([0, 0]: none; a label along a line isn't moved), "repeat"?: points (10 to 10000) between a line's labels, spaced out from the one at its middle, at most 32 a line (0 or null: the one at its middle; parallel and curved only), "otherPositions"?: bool (a point label tries other positions around the point; point layers only), "showAll"?: bool (every label drawn, over the others where they crowd), "callout"?: true|false|points (5 to 500: a label crowded off its spot moves to a free one nearby, a line drawn back to its feature at most this long; true keeps its length, or 40; false, 0 or null: none; not labels along a line)}"#, | ||
| ), | ||
| // Labels moved by hand (the Move Label tool, as QGIS's): kept by their feature's id. | ||
| C::new("labels.move", "Move Label", "Labeling › Label Placement", |s, p| { | ||
| let feature = p.get("feature").and_then(Value::as_u64).ok_or("feature is a feature's id")?; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| let to = match p.get("to") { | ||
| None => return Err("to is [x, y] (longitude, latitude), or null to put it back".into()), | ||
| Some(Value::Null) => None, | ||
| Some(v) => { | ||
| let a = v.as_array().filter(|a| a.len() == 2).ok_or("to is [x, y] (longitude, latitude)")?; | ||
| let xy = [a.first().and_then(number), a.get(1).and_then(number)]; | ||
| match xy { | ||
| [Some(x), Some(y)] if rustgis_map::symbology::is_label_place([x, y]) => Some([x, y]), | ||
| _ => return Err("to is [x, y]: a longitude (within ±540) and a latitude (within ±90)".into()), | ||
| } | ||
| } | ||
| }; | ||
| // A class at its most drops the moves of features since deleted, to make room. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug (stale moves reach a new feature with a reused id), confidence high. Suggested fix: drop a feature's moves when it leaves the layer, or when a new row takes its id. For example, in |
||
| let layer = s.layer_id(p).ok().and_then(|id| s.map().layer(id)); | ||
| if to.is_some() && layer.is_some_and(|l| !l.dataset.features.iter().any(|f| f.id == feature)) { | ||
| return Err("feature is not present in the layer's loaded features".into()); | ||
| } | ||
| let live: Option<std::collections::HashSet<u64>> = layer | ||
| .filter(|l| !l.dataset.windowed) | ||
| .filter(|l| l.labels.in_order().any(|c| c.moved.len() >= rustgis_map::symbology::MAX_MOVED_LABELS)) | ||
| .map(|l| l.dataset.features.iter().map(|f| f.id).collect()); | ||
| let r = with_labels(s, p, |labels| { | ||
| let c = class_in(labels, p)?; | ||
| if let Some(live) = &live | ||
| && c.moved.len() >= rustgis_map::symbology::MAX_MOVED_LABELS | ||
| { | ||
| c.moved.retain(|m| live.contains(&m.id)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| if c.move_label(feature, to) { | ||
| Ok(()) | ||
| } else { | ||
| Err(format!("a label class keeps at most {} labels moved", rustgis_map::symbology::MAX_MOVED_LABELS)) | ||
| } | ||
| })?; | ||
| // Locked, the labels are locked again where they are placed next: the one moved may | ||
| // not have been placed when the lock was taken. | ||
| s.map_mut().label_lock = None; | ||
| Ok(r) | ||
| }) | ||
| .when(need_feature_layer) | ||
| .params(r#"{"layer"?, "class"?: name (none: the Default class), "feature": the feature's id, "to": [x, y] (longitude, latitude: where the label's text is centred) | null (back where its class puts it)}"#), | ||
| C::new("labels.resetMoved", "Reset Moved", "Labeling › Label Placement", |s, p| { | ||
| let all = match p.get("all") { | ||
| None | Some(Value::Null) => false, | ||
| Some(v) => v.as_bool().ok_or("all must be true or false")?, | ||
| }; | ||
| let r = with_labels(s, p, |labels| { | ||
| if all { | ||
| labels.default.moved.clear(); | ||
| labels.classes.iter_mut().for_each(|c| c.moved.clear()); | ||
| } else { | ||
| class_in(labels, p)?.moved.clear(); | ||
| } | ||
| Ok(()) | ||
| })?; | ||
| // As after a move: those put back are locked again where they are placed next. | ||
| s.map_mut().label_lock = None; | ||
| Ok(r) | ||
| }) | ||
| .when(need_feature_layer) | ||
| .params(r#"{"layer"?, "class"?: name (none: the Default class), "all"?: bool (every class of the layer)}"#), | ||
| // Label classes: each labels every feature its clause takes, as in ArcGIS Pro; the classes | ||
| // listed first, Default last, are placed first (`Labeling`, #732). | ||
| C::new("labels.class.add", "Add Label Class", "View › Windows › Labeling", add_class) | ||
|
|
@@ -524,6 +621,7 @@ fn convert(s: &mut Session, p: &Value) -> CmdResult { | |
| GeometryType::Point, | ||
| out_fields, | ||
| ); | ||
| let mut moved = Vec::new(); | ||
| for (row, ft) in l.dataset.features.iter().enumerate() { | ||
| if rows.get(row) != Some(&true) { | ||
| continue; | ||
|
|
@@ -549,6 +647,10 @@ fn convert(s: &mut Session, p: &Value) -> CmdResult { | |
| if classes { | ||
| attrs.push(Attr::Text(class.name.clone())); | ||
| } | ||
| // A label moved by hand becomes an annotation where it was put, and stays moved there | ||
| // (its class's offset leaves it). | ||
| let put = class.moved_to(ft.id); | ||
| let at = put.map_or(at, |[x, y]| Coord::new(x, y)); | ||
| let e = exprs.get(i).and_then(Option::as_ref); | ||
| let number = |v: Option<f32>| v.map_or(Attr::Null, |v| Attr::Float(f64::from(v))); | ||
| if by_size { | ||
|
|
@@ -563,6 +665,9 @@ fn convert(s: &mut Session, p: &Value) -> CmdResult { | |
| attrs.push(number(e.filter(|_| turns).and_then(|e| e.angle(&l.dataset, row)))); | ||
| } | ||
| ds.push(Some(Geometry::Point(at)), attrs); | ||
| if let (Some(put), Some(new)) = (put, ds.features.last()) { | ||
| moved.push((i, new.id, put)); | ||
| } | ||
| } | ||
| } | ||
| if ds.features.is_empty() { | ||
|
|
@@ -586,6 +691,8 @@ fn convert(s: &mut Session, p: &Value) -> CmdResult { | |
| // On its point, never round it: an annotation stays where it is (#1011). | ||
| c.placement = LabelPlacement::Center; | ||
| c.other_positions = false; | ||
| // The annotations' ids aren't the features': each moved label is already at its place. | ||
| c.moved.clear(); | ||
| // Each class picks the points it labelled, Default too once there are others (#732): | ||
| // with no clause it would label theirs as well. | ||
| c.where_ = classes.then(|| format!("\"CLASS\" = '{}'", c.name.replace('\'', "''"))); | ||
|
|
@@ -598,6 +705,10 @@ fn convert(s: &mut Session, p: &Value) -> CmdResult { | |
| angle: field(by_angle, &c.by.angle, "ANGLE"), | ||
| }; | ||
| } | ||
| // (Numbered as the labeling order is: the classes, then Default.) | ||
| for (class, id, at) in moved { | ||
| labels.classes.get_mut(class).unwrap_or(&mut labels.default).move_label(id, Some(at)); | ||
| } | ||
| let (min_scale, max_scale) = (l.min_scale, l.max_scale); | ||
| if let Some(src) = s.map_mut().layer_mut(id) { | ||
| src.labels.enabled = false; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: opengeos/RustGIS
Length of output: 3361
Script executed:
Repository: opengeos/RustGIS
Length of output: 6452
Script executed:
Repository: opengeos/RustGIS
Length of output: 8060
Script executed:
Repository: opengeos/RustGIS
Length of output: 10204
Script executed:
Repository: opengeos/RustGIS
Length of output: 4424
Refresh the label lock when Show All changes.
Enabling Show All on a class keeps the existing label lock. The lock holds only the labels placed when it was taken, so labels omitted at that time stay hidden. Include
show_allinlock_holds, as the other-positions setting already does.Suggested fix
pub(crate) fn lock_holds(a: &Labeling, b: &Labeling) -> bool { - same_class_list(a, b) && same_places(a, b) + same_class_list(a, b) + && same_places(a, b) + && a.classes.iter().chain([&a.default]).zip(b.classes.iter().chain([&b.default])).all(|(x, y)| x.show_all == y.show_all) }