diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index a12ac7c6d..05c77089b 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -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 { })) } +/// Registers undoable label styling, placement and manual moves, plus map display controls. pub fn commands() -> Vec { vec![ C::new("labels.lock", "Lock", "Labeling › Map Labeling", |s, p| { @@ -135,8 +137,8 @@ pub fn commands() -> Vec { // 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 { _ => 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 { 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), + 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")?; + 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. + 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> = 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)); + } + 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| 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; diff --git a/crates/engine/src/cmd/repair.rs b/crates/engine/src/cmd/repair.rs index 4ab30da48..aa36e8373 100644 --- a/crates/engine/src/cmd/repair.rs +++ b/crates/engine/src/cmd/repair.rs @@ -28,6 +28,7 @@ fn replacement(p: &Value) -> Result { Ok(full) } +/// Reopens linked data after compatibility checks; undo restores the source and any reset label moves. fn repair_source(s: &mut Session, p: &Value) -> CmdResult { #[cfg(target_arch = "wasm32")] { @@ -96,7 +97,18 @@ fn repair_source(s: &mut Session, p: &Value) -> CmdResult { )); } } - s.map_mut().layer_mut(id).ok_or("the layer no longer exists")?.replace_source(new); + let l = s.map_mut().layer_mut(id).ok_or("the layer no longer exists")?; + let moved = l.replace_source(new); + let name = l.name.clone(); + // A lock's feature ids and callout offsets belong to the old source too. + s.map_mut().label_lock = None; + s.map_mut().label_lock_line_anchors.clear(); + if moved > 0 { + s.notify( + format!("{moved} saved label move(s) reset in {name}: replacement data may reuse feature ids; undo restores them"), + false, + ); + } } Ok(json!({"layer": id, "path": full, "table": s.map().layer(id).and_then(|l| l.linked_table())})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6ed380577..a8f8f8412 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -291,6 +291,9 @@ pub enum Tool { CreatePolygon, /// Moves, inserts and deletes the vertices of the selected features. EditVertices, + /// Drags a layer's label to where it should be (`labels.move`), as QGIS's Move Label tool; + /// dragging anything else pans the map. + MoveLabel, /// Draws a line across the selected polygons to cut them (`edit.cutPolygon`). CutPolygon, /// Draws a line across the selected feature's boundary to reshape it (`edit.reshape`). @@ -329,6 +332,7 @@ impl Tool { Tool::CreateLine => "createLine", Tool::CreatePolygon => "createPolygon", Tool::EditVertices => "editVertices", + Tool::MoveLabel => "moveLabel", Tool::CutPolygon => "cutPolygon", Tool::Reshape => "reshape", Tool::Graphic(shape) => GRAPHIC_TOOLS.iter().find(|g| g.0 == shape).map_or("graphicPoint", |g| g.1), @@ -351,6 +355,7 @@ impl Tool { Tool::CreateLine, Tool::CreatePolygon, Tool::EditVertices, + Tool::MoveLabel, Tool::CutPolygon, Tool::Reshape, ] @@ -371,6 +376,7 @@ impl Tool { Tool::CreateLine => "Create Line", Tool::CreatePolygon => "Create Polygon", Tool::EditVertices => "Edit Vertices", + Tool::MoveLabel => "Move Label", Tool::CutPolygon => "Cut Polygon", Tool::Reshape => "Reshape", Tool::Graphic(shape) => GRAPHIC_TOOLS.iter().find(|g| g.0 == shape).map_or("Point", |g| g.2), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f7a342f91..52c52d3ad 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3114,6 +3114,144 @@ fn labels_take_a_font_by_family_name() { assert!(s.run("labels.fonts", &json!({})).unwrap()["fonts"].is_array()); } +/// A streamed window cannot discard saved moves for off-window features to make space at the cap. +#[test] +fn moved_labels_preserve_off_window_features_at_the_cap() { + use rustgis_map::symbology::{MAX_MOVED_LABELS, MovedLabel}; + let mut s = world(); + let id = s.map().layer_by_name("Cities").unwrap().id; + let fid = s.map().layer(id).unwrap().dataset.features.first().unwrap().id; + let layer = s.map_mut().layer_mut(id).unwrap(); + layer.dataset.windowed = true; + layer.labels.default.moved = (0..MAX_MOVED_LABELS).map(|i| MovedLabel { id: 1_000_000 + i as u64, at: [1.0, 1.0] }).collect(); + let error = s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [2, 2]})).unwrap_err(); + assert!(error.contains("at most"), "{error}"); + assert_eq!(s.map().layer(id).unwrap().labels.default.moved.len(), MAX_MOVED_LABELS); + let error = s.run("labels.move", &json!({"layer": id, "feature": u64::MAX, "to": [2, 2]})).unwrap_err(); + assert!(error.contains("not present"), "{error}"); +} + +/// Labels are moved by hand by feature id and put back, one class's or all; bad input is refused. +#[test] +fn labels_are_moved_by_command_and_reset() { + let mut s = world(); + let id = s.map().layer_by_name("Cities").unwrap().id; + let fid = s.map().layer(id).unwrap().dataset.features.first().unwrap().id; + let moved = |s: &Session| s.map().layer(id).unwrap().labels.default.moved.clone(); + s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [12.5, 41.9]})).unwrap(); + assert_eq!(moved(&s), vec![rustgis_map::symbology::MovedLabel { id: fid, at: [12.5, 41.9] }]); + // By the class's name too (Default's); again moves it again. + let name = s.map().layer(id).unwrap().labels.default.name.clone(); + s.run("labels.move", &json!({"layer": id, "class": name, "feature": fid, "to": [13.0, 42.0]})).unwrap(); + assert_eq!(moved(&s).len(), 1); + // Bad values change nothing. + for bad in [ + json!({"feature": fid}), + json!({"feature": "one", "to": [1, 1]}), + json!({"feature": fid, "to": [1]}), + json!({"feature": fid, "to": [1, 200]}), + json!({"feature": fid, "to": ["a", 1]}), + json!({"feature": fid, "to": [1, 1], "class": "No such class"}), + ] { + let mut p = bad.clone(); + p["layer"] = json!(id); + assert!(s.run("labels.move", &p).is_err(), "{bad}"); + } + assert_eq!(moved(&s)[0].at, [13.0, 42.0]); + // Saved with the project's layer, and one undo step a move. + let saved = serde_json::to_value(&s.map().layer(id).unwrap().labels).unwrap(); + assert_eq!(saved["moved"], json!([{"id": fid, "at": [13.0, 42.0]}])); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(moved(&s)[0].at, [12.5, 41.9]); + // Put back: one, then all. + s.run("labels.move", &json!({"layer": id, "feature": fid, "to": null})).unwrap(); + assert!(moved(&s).is_empty()); + s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [1, 1]})).unwrap(); + // A bad "all" is refused, not taken as false. + assert!(s.run("labels.resetMoved", &json!({"layer": id, "all": "tru"})).is_err()); + assert_eq!(moved(&s).len(), 1); + s.run("labels.class.add", &json!({"layer": id, "name": "Named"})).unwrap(); + s.run("labels.move", &json!({"layer": id, "class": "Named", "feature": fid, "to": [3, 3]})).unwrap(); + s.run("labels.resetMoved", &json!({"layer": id, "all": true})).unwrap(); + assert!(moved(&s).is_empty()); + assert!(s.map().layer(id).unwrap().labels.class("Named").unwrap().moved.is_empty(), "every class's"); + s.run("labels.class.remove", &json!({"layer": id, "class": "Named"})).unwrap(); + // Locked, a move locks the labels again where they are placed next: the one moved may not + // have been placed when the lock was taken. + s.run("labels.lock", &json!({"on": true})).unwrap(); + let v = crate::render::View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; + crate::render::render(s.map_mut(), &v).unwrap(); + assert!(s.map().label_lock.is_some()); + s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [2, 2]})).unwrap(); + assert!(s.map().label_lock.is_none() && s.map().label_display.locked, "locked again, not unlocked"); + // Converted to annotations, a moved label's annotation is where it was put, and the + // annotations' classes keep none moved (their ids aren't the features'). + s.run("labels.lock", &json!({"on": false})).unwrap(); + let r = s.run("labels.convert", &json!({"layer": id})).unwrap(); + let a = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); + // It stays moved there under its new id, so its class's offset doesn't shift it. + let at = |g: &Option| match g { + Some(rustgis_data::Geometry::Point(c)) => Some([c.x, c.y]), + _ => None, + }; + let put = a.dataset.features.iter().find(|f| at(&f.geometry) == Some([2.0, 2.0])).expect("the moved one's annotation"); + assert_eq!(a.labels.default.moved, vec![rustgis_map::symbology::MovedLabel { id: put.id, at: [2.0, 2.0] }]); + // The tool by its code. + s.run("tool.set", &json!({"tool": "moveLabel"})).unwrap(); + assert_eq!(s.tool, crate::Tool::MoveLabel); +} + +#[test] +fn labels_show_all_and_take_callouts() { + // A project file's 0 is off, as the command's is; a length is kept. + let ds = rustgis_data::Dataset::new("d", rustgis_data::GeometryType::Point, vec![]); + let mut v = serde_json::to_value(rustgis_map::LabelClass::default_for(&ds)).unwrap(); + for (file, read) in [(json!(0), None), (json!(-3), None), (json!(30), Some(30.0))] { + v["callout"] = file; + assert_eq!(serde_json::from_value::(v.clone()).unwrap().callout, read); + } + let mut s = world(); + let class = |s: &Session| { + let c = &s.map().layer_by_name("Cities").unwrap().labels.default; + (c.show_all, c.callout) + }; + assert_eq!(class(&s), (false, None)); + s.run("labels.placement", &json!({"layer": "Cities", "showAll": true})).unwrap(); + assert_eq!(class(&s), (true, None), "no position needed"); + // On: the default length, then one given, kept within range; on again keeps it. + s.run("labels.placement", &json!({"layer": "Cities", "callout": true})).unwrap(); + assert_eq!(class(&s).1, Some(rustgis_map::symbology::DEFAULT_CALLOUT)); + s.run("labels.placement", &json!({"layer": "Cities", "callout": 80})).unwrap(); + assert_eq!(class(&s).1, Some(80.0)); + s.run("labels.placement", &json!({"layer": "Cities", "callout": 99999})).unwrap(); + assert_eq!(class(&s).1, Some(500.0), "within its range"); + s.run("labels.placement", &json!({"layer": "Cities", "callout": true})).unwrap(); + assert_eq!(class(&s).1, Some(500.0), "on again keeps its length"); + // Saved with the layer, and read back. + let saved = serde_json::to_value(&s.map().layer_by_name("Cities").unwrap().labels).unwrap(); + assert_eq!((saved["show_all"].clone(), saved["callout"].clone()), (json!(true), json!(500.0))); + let back: rustgis_map::symbology::Labeling = serde_json::from_value(saved).unwrap(); + assert_eq!((back.default.show_all, back.default.callout), (true, Some(500.0))); + // A project file's out-of-range length is brought within range. + let mut wild = serde_json::to_value(&back).unwrap(); + wild["callout"] = json!(1e9); + let read: rustgis_map::symbology::Labeling = serde_json::from_value(wild).unwrap(); + assert_eq!(read.default.callout, Some(500.0)); + // Off, three ways; bad values change nothing. + for off in [json!(false), json!(0), json!(null)] { + s.run("labels.placement", &json!({"layer": "Cities", "callout": 40})).unwrap(); + s.run("labels.placement", &json!({"layer": "Cities", "callout": off})).unwrap(); + assert_eq!(class(&s).1, None, "{off}"); + } + for bad in [json!({"callout": "long"}), json!({"callout": -5}), json!({"showAll": "yes"})] { + let mut p = bad.clone(); + p["layer"] = json!("Cities"); + assert!(s.run("labels.placement", &p).is_err(), "{bad}"); + } + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(class(&s), (true, Some(40.0)), "undoes"); +} + /// A label class's halo takes its own colour and width, and its letters a spacing; blank and null /// go back to the automatic colour and the thin halo, and other settings leave them. #[test] @@ -3372,7 +3510,7 @@ fn changing_line_repeat_relocks_alternative_positions() { let mut s = world(); let id = s.map().layer_by_name("Rivers").unwrap().id; s.map_mut().layer_mut(id).unwrap().labels.default.other_positions = true; - s.map_mut().label_lock = Some(vec![((id, 0, 0, 0), 1)]); + s.map_mut().label_lock = Some(vec![((id, 0, 0, 0), rustgis_map::LockedPlace { place: 1, callout: None })]); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 144})).unwrap(); assert!(s.map().label_lock.is_none()); } @@ -18779,7 +18917,7 @@ fn ogc_group_refresh_keeps_children_across_rotated_keys_and_rejects_bad_answers( let group = added["group"].clone(); let ids: Vec = s.map().service_groups[0].children.iter().map(|c| c.layer).collect(); s.run("layer.rename", &json!({"layer":ids[0], "name":"Renamed"})).unwrap(); - s.map_mut().label_lock = Some(vec![((ids[0], 0, 0, 0), 0), ((ids[1], 0, 0, 0), 0)]); + s.map_mut().label_lock = Some(vec![((ids[0], 0, 0, 0), Default::default()), ((ids[1], 0, 0, 0), Default::default())]); // A rotated key changes every source address but not the collections: the children stay. let mut q = doc("rotated-key"); diff --git a/crates/engine/tests/portable_projects/repair.rs b/crates/engine/tests/portable_projects/repair.rs index 1045ea180..b5213c31f 100644 --- a/crates/engine/tests/portable_projects/repair.rs +++ b/crates/engine/tests/portable_projects/repair.rs @@ -259,3 +259,32 @@ fn compatible_raster_repair_keeps_nondefault_display_settings() { assert_eq!(raster.data.as_ref().unwrap().band_count(), 3); assert!(raster.error.is_none()); } + +/// Replacing data must not transfer a saved label move to a different feature with the same id. +#[test] +fn replacing_source_resets_saved_label_moves_with_notice_and_undo() { + let f = Fixture::new("repair-label-moves"); + let original = f.file("original.fgb"); + let replacement = f.file("replacement.fgb"); + sample_file(&original, "Cities"); + sample_file(&replacement, "Cities"); + let mut s = session(); + let id = add_stream(&mut s, &original, None); + let fid = 1; + let l = s.map_mut().layer_mut(id).unwrap(); + l.labels.default.moved.push(rustgis_engine::map::symbology::MovedLabel { id: fid, at: [12.0, 34.0] }); + s.map_mut().label_lock = Some(vec![((id, fid, 0, 0), rustgis_engine::map::LockedPlace { place: 0, callout: Some([20.0, 10.0]) })]); + let mark = s.notice_mark(); + s.run("layer.repairSource", &json!({"layer": id, "path": replacement})).unwrap(); + assert!(s.map().layer(id).unwrap().labels.default.moved.is_empty()); + assert!(s.map().label_lock.is_none(), "old callout locks must not attach to replacement ids"); + let notices = s.notices_since(mark); + assert_eq!(notices.len(), 1); + assert!(notices[0].text.contains("1 saved label move(s) reset")); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.map().layer(id).unwrap().labels.default.moved[0].at, [12.0, 34.0]); + s.run("edit.redo", &json!({})).unwrap(); + let mark = s.notice_mark(); + s.run("layer.repairSource", &json!({"layer": id, "path": original})).unwrap(); + assert!(s.notices_since(mark).is_empty(), "no moves means no loss notice"); +} diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index a2d4a1912..f634725ac 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -41,6 +41,15 @@ pub use symbology::{ }; pub use tiles::{TileKind, TileLayer}; +/// Where a locked label stays. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct LockedPlace { + /// The place it took: 0 its own, else one of its alternatives (#1011), numbered from 1. + pub place: u8, + /// How far a callout moved it from there (points, right and down), if one did. + pub callout: Option<[f32; 2]>, +} + use rustgis_data::{Dataset, Geometry, GeometryType}; use rustgis_geom::{Coord, Extent}; use serde::{Deserialize, Serialize}; @@ -344,9 +353,13 @@ impl Layer { }) } - /// Installs a fresh source without changing the layer's identity or configuration. - pub fn replace_source(&mut self, src: source::SourceLayer) { + /// Installs a fresh source, retaining styling but clearing feature-specific state whose ids + /// may identify different features. Returns the number of saved label moves reset for a notice. + pub fn replace_source(&mut self, src: source::SourceLayer) -> usize { self.selection.clear(); + let moved = self.labels.in_order().map(|class| class.moved.len()).sum(); + self.labels.default.moved.clear(); + self.labels.classes.iter_mut().for_each(|class| class.moved.clear()); if let Some(info) = src.info() { self.dataset.fields = info.fields.clone(); self.dataset.crs = info.coordinate_system(); @@ -356,6 +369,7 @@ impl Layer { self.dataset.source = src.linked_file().map(String::from); self.source = Some(src); self.install(Vec::new()); + moved } /// The dataset, streamed, raster and time-following Zarr addresses used to read this layer @@ -1225,11 +1239,10 @@ pub struct Map { /// Map-wide label display switches. #[serde(default)] pub label_display: LabelDisplay, - /// The labels placed when labels were locked, each with the place it took (0 its own, else - /// one of its alternatives, #1011): only they are drawn while the lock holds, there, wherever - /// the map moves. Filled by the renderer. + /// The labels placed when labels were locked, each with the place it took: only they are + /// drawn while the lock holds, there, wherever the map moves. Filled by the renderer. #[serde(skip)] - pub label_lock: Option>, + pub label_lock: Option>, /// Geographic anchors of locked non-repeated line labels. Render cache only, never saved. #[serde(skip)] pub label_lock_line_anchors: std::collections::HashMap, diff --git a/crates/map/src/qgis/labels.rs b/crates/map/src/qgis/labels.rs index 49b5a830d..dd8016f28 100644 --- a/crates/map/src/qgis/labels.rs +++ b/crates/map/src/qgis/labels.rs @@ -313,6 +313,9 @@ fn class( wrap_length, line_align, repeat, + show_all: false, + callout: None, + moved: Vec::new(), by, }) } diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 212b2059f..5c6081897 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -2156,12 +2156,146 @@ pub struct LabelClass { /// the one at its middle, as QGIS's repeat distance; none is the one at its middle. #[serde(default, deserialize_with = "de_repeat", skip_serializing_if = "Option::is_none")] pub repeat: Option, + /// Every label drawn, over the others where they crowd, rather than some left out. + #[serde(default, deserialize_with = "de_flag", skip_serializing_if = "std::ops::Not::not")] + pub show_all: bool, + /// Callouts: a label crowded off its spot moves to a free one nearby, a line drawn from its + /// feature to it at most this long (points, within [`CALLOUT_REACH`]), as QGIS's callouts; + /// none keeps every label in its spot. Labels along lines aren't moved. + #[serde(default, deserialize_with = "de_callout", skip_serializing_if = "Option::is_none")] + pub callout: Option, + /// Labels moved by hand (the Move Label tool, as QGIS's): each feature's, by its id, drawn + /// where it was put, a callout's line back to the feature when the class has callouts. At + /// most [`MAX_MOVED_LABELS`], kept in order of id (so a label's is found without a scan). + #[serde(default, deserialize_with = "de_moved", skip_serializing_if = "Vec::is_empty")] + pub moved: Vec, /// Its text, size, colour and angle per feature, each by an expression (none set: the field, /// size and colour above alone). #[serde(default, skip_serializing_if = "crate::label_by::LabelBy::is_empty", deserialize_with = "crate::label_by::de_label_by")] pub by: crate::label_by::LabelBy, } +/// A label moved by hand: its feature's id, and where its text is centred, in the layer's +/// coordinates (longitude and latitude), so it stays put on the map as the view moves. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct MovedLabel { + pub id: u64, + pub at: [f64; 2], +} + +/// Most labels a class keeps moved by hand. +pub const MAX_MOVED_LABELS: usize = 10_000; + +/// Whether `at` is a place a label can be moved to: a longitude within ±540° (a view's worth +/// past the antimeridian either way) and a latitude within ±90°. +pub fn is_label_place(at: [f64; 2]) -> bool { + let [x, y] = at; + x.is_finite() && y.is_finite() && x.abs() <= 540.0 && y.abs() <= 90.0 +} + +/// A project file's moved labels: those with a place ([`is_label_place`]), one a feature (the +/// last wins), at most [`MAX_MOVED_LABELS`], in order of id. +fn de_moved<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + /// Reads one entry at a time, retaining only the capped map of valid label positions. + struct Moves; + impl<'de> serde::de::Visitor<'de> for Moves { + type Value = Vec; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("an array of moved labels") + } + + /// Buffers only one entry and the capped, deduplicated set of valid positions. + fn visit_seq>(self, mut seq: A) -> Result { + let mut by_id = std::collections::BTreeMap::new(); + while let Some(value) = seq.next_element::()? { + if let Ok(m) = serde_json::from_value::(value) + && is_label_place(m.at) + && (by_id.len() < MAX_MOVED_LABELS || by_id.contains_key(&m.id)) + { + by_id.insert(m.id, m.at); + } + } + Ok(by_id.into_iter().map(|(id, at)| MovedLabel { id, at }).collect()) + } + + /// Skips a damaged object without allocating it or rejecting the surrounding class. + fn visit_map>(self, mut map: A) -> Result { + while map.next_entry::()?.is_some() {} + Ok(Vec::new()) + } + + fn visit_bool(self, _: bool) -> Result { + Ok(Vec::new()) + } + fn visit_i64(self, _: i64) -> Result { + Ok(Vec::new()) + } + fn visit_u64(self, _: u64) -> Result { + Ok(Vec::new()) + } + fn visit_f64(self, _: f64) -> Result { + Ok(Vec::new()) + } + fn visit_str(self, _: &str) -> Result { + Ok(Vec::new()) + } + fn visit_unit(self) -> Result { + Ok(Vec::new()) + } + } + d.deserialize_any(Moves) +} + +impl LabelClass { + /// Where feature `id`'s label was moved to by hand, if it was. + pub fn moved_to(&self, id: u64) -> Option<[f64; 2]> { + self.moved.binary_search_by_key(&id, |m| m.id).ok().and_then(|i| self.moved.get(i)).map(|m| m.at) + } + + /// Keeps feature `id`'s label at `at` (longitude, latitude), or where its class puts it + /// again with none. False when `at` isn't a place ([`is_label_place`]), or the class already + /// keeps the most. + pub fn move_label(&mut self, id: u64, at: Option<[f64; 2]>) -> bool { + let found = self.moved.binary_search_by_key(&id, |m| m.id); + match (at, found) { + (None, Ok(i)) => { + self.moved.remove(i); + true + } + (None, Err(_)) => true, + (Some(at), _) if !is_label_place(at) => false, + (Some(at), Ok(i)) => { + if let Some(m) = self.moved.get_mut(i) { + m.at = at; + } + true + } + (Some(at), Err(i)) if self.moved.len() < MAX_MOVED_LABELS => { + self.moved.insert(i, MovedLabel { id, at }); + true + } + (Some(_), Err(_)) => false, + } + } +} + +/// How long a callout's line may be (points): from about a letter to a few centimetres of paper. +pub const CALLOUT_REACH: std::ops::RangeInclusive = 5.0..=500.0; +/// A callout's longest line when none is given (points). +pub const DEFAULT_CALLOUT: f32 = 40.0; + +/// A callout's longest line as a class keeps it, within [`CALLOUT_REACH`]; none for no number. +pub fn callout_reach(v: f64) -> Option { + v.is_finite().then(|| v.clamp(*CALLOUT_REACH.start(), *CALLOUT_REACH.end()) as f32) +} + +/// Reads positive finite lengths within the supported range; damaged and disabled values stay off. +fn de_callout<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + // (0 turns callouts off, as the command has it; a negative isn't a length either.) + Ok(lenient::(d)?.filter(|v| *v > 0.0).and_then(callout_reach)) +} + /// The longest wrap character kept (characters). pub const MAX_WRAP_CHAR: usize = 8; /// The longest line length wrapping keeps to (characters). @@ -2589,6 +2723,9 @@ impl LabelClass { wrap_length: 0, line_align: crate::layout::TextAlign::Left, repeat: None, + show_all: false, + callout: None, + moved: Vec::new(), by: Default::default(), } } @@ -3855,6 +3992,47 @@ mod tests { assert_eq!(e.label, "x".repeat(MAX_LABEL - 1)); } + #[test] + fn labels_are_moved_by_hand_and_put_back() { + let ds = rustgis_data::Dataset::new( + "d", + rustgis_data::GeometryType::Point, + vec![rustgis_data::Field::new("name", rustgis_data::FieldType::Text)], + ); + let mut c = LabelClass::default_for(&ds); + assert!(c.move_label(7, Some([10.0, 20.0])) && c.move_label(8, Some([1.0, 2.0]))); + assert!(c.move_label(7, Some([11.0, 21.0])), "moved again, the same feature's"); + assert_eq!((c.moved_to(7), c.moved_to(8), c.moved.len()), (Some([11.0, 21.0]), Some([1.0, 2.0]), 2)); + assert!(!c.move_label(9, Some([f64::NAN, 0.0])), "not a place"); + assert!(!c.move_label(9, Some([1000.0, 0.0])) && !c.move_label(9, Some([0.0, 95.0])), "off the map"); + assert!(c.move_label(2, Some([0.0, 0.0])) && c.moved.windows(2).all(|w| w[0].id < w[1].id), "kept in order of id"); + assert!(c.move_label(2, None) && c.move_label(2, None), "putting back one not moved is fine"); + assert!(c.move_label(8, None) && c.moved_to(8).is_none(), "put back"); + // Read from a file: bad entries dropped, the rest kept, a feature's last place wins. + let mut v = serde_json::to_value(&c).unwrap(); + v["moved"] = serde_json::json!([{"id": 1, "at": [1, 1]}, {"id": "x"}, 7, {"id": 1, "at": [2, 2]}, {"id": 3, "at": [3, 3]}, {"id": 0, "at": [1000, 95]}]); + let read: LabelClass = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(read.moved, vec![MovedLabel { id: 1, at: [2.0, 2.0] }, MovedLabel { id: 3, at: [3.0, 3.0] }]); + // Out of order in the file: put in order, so each is still found. + v["moved"] = serde_json::json!([{"id": 9, "at": [9, 9]}, {"id": 4, "at": [4, 4]}]); + let read: LabelClass = serde_json::from_value(v.clone()).unwrap(); + assert_eq!((read.moved_to(4), read.moved_to(9)), (Some([4.0, 4.0]), Some([9.0, 9.0]))); + // A huge list reads quickly, and keeps the first the file lists, up to the most kept. + let many: Vec<_> = (0..50_000u64).rev().map(|i| serde_json::json!({"id": i, "at": [0, 0]})).collect(); + v["moved"] = serde_json::Value::Array(many); + let read: LabelClass = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(read.moved.len(), MAX_MOVED_LABELS); + v["moved"] = serde_json::json!("nonsense"); + assert!(serde_json::from_value::(v.clone()).unwrap().moved.is_empty(), "the rest still reads"); + v["show_all"] = serde_json::json!("yes"); + v["moved"] = serde_json::json!({"damaged": [1, 2, 3]}); + let read: LabelClass = serde_json::from_str(&v.to_string()).unwrap(); + assert!(!read.show_all && read.moved.is_empty(), "damaged optional fields leave the class usable"); + // None moved: none saved. + let none = serde_json::to_value(LabelClass::default_for(&ds)).unwrap(); + assert!(none.get("moved").is_none()); + } + #[test] fn class_labels_are_saved_and_older_renderers_still_load() { let g = GeometryType::Point; diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 33b1475e3..5fe56ead0 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -1549,7 +1549,7 @@ pub fn frame_coverage(map: &mut Map, center: Coord, scale: f64, w_mm: f64, h_mm: basemap: rustgis_map::Basemap, tiles: Vec, graticule: bool, - lock: Option>, + lock: Option>, anchors: std::collections::HashMap, map: &'a mut Map, } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fd00bd662..eef6cfda8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -33,6 +33,7 @@ mod stroke_paths; pub mod svg; pub mod terrain; mod text; +pub use text::CALLOUT_WIDTH; pub mod tile_mesh; mod tiles; pub mod vector; @@ -2222,6 +2223,12 @@ fn lift_labels(view: &View, ground: &terrain::Ground, candidates: &mut [Candidat (dx.is_finite() && dy.is_finite()).then_some((dx, dy)) }; for c in candidates.iter_mut() { + if let Some([ax, ay]) = c.label.collide.callout_anchor { + let (fx, fy) = (c.label.x + ax, c.label.y + ay); + if let (Some((fdx, fdy)), Some((dx, dy))) = (offset(fx, fy), offset(c.label.x, c.label.y)) { + c.label.collide.callout_anchor = Some([ax + fdx - dx, ay + fdy - dy]); + } + } if let Some((dx, dy)) = offset(c.label.x, c.label.y) { c.label.x += dx; c.label.y += dy; @@ -2481,6 +2488,12 @@ fn raise_labels(map: &Map, view: &View, candidates: &mut [Candidate]) { let delta = (sx as f32 - x, sy as f32 - y); (delta.0.is_finite() && delta.1.is_finite()).then_some(delta) }; + if let Some([ax, ay]) = l.collide.callout_anchor { + let (fx, fy) = (l.x + ax, l.y + ay); + if let (Some((fdx, fdy)), Some((dx, dy))) = (offset(fx, fy), offset(l.x, l.y)) { + l.collide.callout_anchor = Some([ax + fdx - dx, ay + fdy - dy]); + } + } if let Some((dx, dy)) = offset(l.x, l.y) { l.x += dx; l.y += dy; @@ -3317,7 +3330,8 @@ fn render_frame( map.label_lock = None; map.label_lock_line_anchors.clear(); } - let lock: Option> = map.label_lock.as_ref().map(|v| v.iter().copied().collect()); + let lock: Option> = + map.label_lock.as_ref().map(|v| v.iter().copied().collect()); // The classes of features with a label locked (any of its repeats), to pass the rest over early. let locked_classes: Option> = lock.as_ref().map(|k| k.keys().map(|&(l, r, c, _)| (l, r, c)).collect()); @@ -3874,8 +3888,28 @@ fn render_frame( .map(|(c, _)| c.offset[0].abs().max(c.offset[1].abs()) * 1.33 * label_factor) .filter(|r| r.is_finite()) .fold(0.0, f32::max); - let offset_reach = f64::from(offset_reach); + let locked_reach = lock + .as_ref() + .into_iter() + .flat_map(|locks| locks.iter()) + .filter(|((layer, ..), _)| *layer == lid) + .filter_map(|(_, place)| place.callout) + .map(|[x, y]| x.abs().max(y.abs())) + .filter(|r| r.is_finite()) + .fold(0.0, f32::max); + let offset_reach = f64::from(offset_reach.max(locked_reach)); let (vw, vh) = (ctx.view.width, ctx.view.height); + // Labels moved by hand (the Move Label tool): any class's, its feature known by + // its id. Where one was put is in view or not whatever its feature's place. + let any_moved = labels.in_order().zip(&label_fields).any(|(c, f)| f.is_some() && !c.moved.is_empty()); + // (Kept as longitude and latitude: the flat map draws its projection's coordinates, + // as its features' `geoms` are; the globe draws longitude and latitude.) + let moved_to = |class: &rustgis_map::LabelClass, fid: Option| { + fid.and_then(|id| class.moved_to(id)) + .map(|[x, y]| Coord::new(x, y)) + .and_then(|c| if globe.is_some() { Some(c) } else { flat.forward_checked(c) }) + .and_then(|c| ctx.screen(c)) + }; for (row, g) in geoms.iter().enumerate() { let Some(g) = g else { continue }; if rows.get(row) != Some(&true) { @@ -3894,7 +3928,14 @@ fn render_frame( None }; let line_in_view = || globe.is_some() || ctx.cull.intersects(&extents.get(row).copied().unwrap_or_else(|| g.extent())); - if point.is_none() && !(any_along && matches!(g, Geometry::Line(_)) && line_in_view()) { + let fid = layer.dataset.features.get(row).map(|f| f.id); + // (Its class's clause read only for a feature with a label moved.) + let moved_here = || { + any_moved + && fid.is_some_and(|id| labels.in_order().any(|c| c.moved_to(id).is_some())) + && classes.classes_of(ds_ptr, row).any(|(_, c)| moved_to(c, fid).is_some()) + }; + if point.is_none() && !(any_along && matches!(g, Geometry::Line(_)) && line_in_view()) && !moved_here() { continue; } let is_point = g.kind() == GeometryType::Point; @@ -3919,9 +3960,14 @@ fn render_frame( if locked_classes.as_ref().is_some_and(|k| !k.contains(&(lid, label_id(row), ci))) { continue; } - if point.is_none() && !along_line(class.placement, g) { + // Moved by hand: one label, upright, where it was put. + let moved = if any_moved { moved_to(class, fid) } else { None }; + if point.is_none() && moved.is_none() && !along_line(class.placement, g) { continue; } + // Its feature's place, for a callout's line back to it: off the screen too + // when the label was moved into view. + let anchor = point.or_else(|| moved.and_then(|_| ctx.screen(c))); // (Along a line, the label is put on its stretch in view below.) let (x, y) = point.unwrap_or_default(); let Some(fi) = label_fields.get(ci).copied().flatten() else { continue }; @@ -3966,7 +4012,9 @@ fn render_frame( let mut curves = None; let mut line_alternatives = Vec::new(); let mut line_places = None; - if let (LabelPlacement::Parallel | LabelPlacement::Curved, Geometry::Line(parts)) = (class.placement, g) { + if let Some((mx, my)) = moved { + places.push((0, mx as f32, my as f32, None, Vec::new())); + } else if let (LabelPlacement::Parallel | LabelPlacement::Curved, Geometry::Line(parts)) = (class.placement, g) { let key = (lid, label_id(row), ci, 0); let repeat = class.repeat.map(|r| r * 1.33 * label_factor); let try_others = class.other_positions && repeat.is_none(); @@ -3994,7 +4042,7 @@ fn render_frame( let overrun = size; let positions = |width| match locked_at { Some(at) => { - let won = lock.as_ref().and_then(|keys| keys.get(&key)).copied().unwrap_or(0); + let won = lock.as_ref().and_then(|keys| keys.get(&key)).map_or(0, |won| won.place); vec![at; (usize::from(won) + 1).min(8)] } None => line_labels::positions(line.length(), width, overrun), @@ -4023,7 +4071,8 @@ fn render_frame( let positions = positions(width); // A saved slot the curve cannot take has no truthful preview either (as for a // straight line): never draw one at the anchor or the middle. - if lock.as_ref().and_then(|k| k.get(&key)).is_some_and(|won| usize::from(*won) >= positions.len()) { + if lock.as_ref().and_then(|k| k.get(&key)).is_some_and(|won| usize::from(won.place) >= positions.len()) + { continue; } line_places = Some(LinePlaces { anchors: anchors(&positions), ..Default::default() }); @@ -4031,7 +4080,7 @@ fn render_frame( let angle = line.parallel(width, overrun, locked_at).map(|(_, _, a)| a); // The preview is also View Unplaced's middle when every curve fails. places.push((0, cx, cy, angle, Vec::new())); - let locked = lock.as_ref().and_then(|k| k.get(&key)).map(|won| usize::from(*won)); + let locked = lock.as_ref().and_then(|k| k.get(&key)).map(|won| usize::from(won.place)); curves = Some(label_places::Curves::new( std::sync::Arc::new(label_places::CurveSpec { line: line.clone(), @@ -4096,8 +4145,9 @@ fn render_frame( // Layers above first, then the classes listed first (#732), then bigger // polygons: a class (ci ≤ MAX_LABEL_CLASSES, 1e9 each) never outweighs a // layer (1e12), nor an area (≤ 1e8) a class. A line's repeats come after - // its label at the middle, nearest first (lines have no area). - let pri = (n_layers - li) as f64 * 1e12 - ci as f64 * 1e9 + area; + // its label at the middle, nearest first (lines have no area). Labels moved + // by hand come before all of them (1e15), so the others keep off them. + let pri = (n_layers - li) as f64 * 1e12 - ci as f64 * 1e9 + area + if moved.is_some() { 1e15 } else { 0.0 }; let last = places.len().saturating_sub(1); for (n, (i, mut lx, mut ly, mut angle, mut glyphs)) in places.into_iter().enumerate() { let key = (lid, label_id(row), ci, i); @@ -4112,7 +4162,7 @@ fn render_frame( class .placement .alternatives() - .filter(|_| class.other_positions) + .filter(|_| class.other_positions && moved.is_none()) .map(place) .map(LabelPlace::from) .collect::>() @@ -4122,9 +4172,9 @@ fn render_frame( }; let alternatives = match lock.as_ref().and_then(|k| k.get(&key)) { Some(&won) => { - if let Some(at) = usize::from(won).checked_sub(1).and_then(|i| others().into_iter().nth(i)) { + if let Some(at) = usize::from(won.place).checked_sub(1).and_then(|i| others().into_iter().nth(i)) { (lx, ly, angle, glyphs) = (at.x, at.y, at.angle, at.glyphs); - } else if won > 0 && !is_point && curves.is_none() { + } else if won.place > 0 && !is_point && curves.is_none() { // Do not slide a locked line to its middle when its saved place // cannot be represented. Without a valid saved place there is also // no truthful View Unplaced preview; never invent one at the middle. @@ -4155,7 +4205,22 @@ fn render_frame( font, line_align: class.line_align, angle, - collide: Default::default(), + collide: rustgis_tiles::Collide { + // Shown all: over the other labels where they crowd. + text_allow_overlap: class.show_all, + // Callouts: a label set by its feature (not along a line) + // may move off a crowded spot, a line drawn back to it. + callout_reach: if angle.is_none() && glyphs.is_empty() { + class.callout.unwrap_or(0.0) + } else { + 0.0 + }, + callout_anchor: anchor.map(|(ax, ay)| [ax as f32 - lx, ay as f32 - ly]), + callout_offset: lock.as_ref().and_then(|k| k.get(&key)).and_then(|won| won.callout), + pinned: moved.is_some(), + source: fid.map(|id| (lid, id, ci as u32)), + ..Default::default() + }, // (Not behind characters curved one by one.) background: class .background @@ -4880,6 +4945,7 @@ fn line_near_anchor(ctx: &Ctx<'_>, parts: &[Vec], anchor: (f32, f32), ove /// own place, or else the first of its alternatives in sight, is where it goes; the rest in sight /// are left to try. Left out with none in sight, or its icon out of sight. fn tilt_candidate(mut c: Candidate, at: &(impl Fn(f32, f32) -> Option<(f32, f32)> + Clone + Send + Sync + 'static)) -> Option { + let feature = c.label.collide.callout_anchor.and_then(|[ax, ay]| at(c.label.x + ax, c.label.y + ay)); if let Some(curves) = &mut c.curves { let mut preview = curves.preview_in(&mut c.label, at)?; preview.swap(&mut c.label); @@ -4905,6 +4971,7 @@ fn tilt_candidate(mut c: Candidate, at: &(impl Fn(f32, f32) -> Option<(f32, f32) } (i.x, i.y) = at(i.x, i.y)?; } + c.label.collide.callout_anchor = feature.map(|(x, y)| [x - c.label.x, y - c.label.y]); Some(c) } @@ -4912,6 +4979,7 @@ fn tilt_candidate(mut c: Candidate, at: &(impl Fn(f32, f32) -> Option<(f32, f32) /// `at` must succeed for the main label and its icon (the turned-view caller is infallible). /// Alternative glyph positions may fail; their slots stay invalid rather than being renumbered. fn turn_candidate(c: &mut Candidate, at: impl Fn(f32, f32) -> Option<(f32, f32)> + Clone + Send + Sync + 'static) { + let feature = c.label.collide.callout_anchor.and_then(|[ax, ay]| at(c.label.x + ax, c.label.y + ay)); turn_with(&mut c.label, &at); if let Some((x, y)) = at(c.label.x, c.label.y) { (c.label.x, c.label.y) = (x, y); @@ -4921,6 +4989,7 @@ fn turn_candidate(c: &mut Candidate, at: impl Fn(f32, f32) -> Option<(f32, f32)> { (i.x, i.y) = (x, y); } + c.label.collide.callout_anchor = feature.map(|(x, y)| [x - c.label.x, y - c.label.y]); for p in &mut c.alternatives { if !p.transform(&mut c.label, &at) { // Preserve the slot number for locks, but never place source-view geometry. @@ -5022,12 +5091,109 @@ fn halo_for(text: Rgba) -> Rgba { struct Placement { labels: Vec, unplaced: Vec, - /// The key of each placed label, and the place it took (0 its own, else its alternative's - /// number from 1). - keys: Vec<(rustgis_map::LabelKey, u8)>, + /// The key of each placed label, and the place it took. + keys: Vec<(rustgis_map::LabelKey, rustgis_map::LockedPlace)>, line_anchors: Vec<(rustgis_map::LabelKey, Coord)>, } +/// Labels a placement looks for a callout's spot for, at most (each looks at up to a few thousand +/// spots). +const MAX_CALLOUT_SEARCHES: usize = 500; + +/// Spots a placement tries for its callouts at most, all its searches together. +const MAX_CALLOUT_SPOTS: usize = 200_000; + +/// A label's text's extent (points, `[x0, y0, x1, y1]`), as placement keeps it clear of the +/// others: what the Move Label tool picks it by. +pub fn label_extent(l: &PlacedLabel) -> Option<[f32; 4]> { + boxes_extent(&label_boxes(l).0) +} + +/// The extent of boxes (`[x0, y0, x1, y1]` each); none for none. +fn boxes_extent(boxes: &[[f32; 4]]) -> Option<[f32; 4]> { + let e = boxes + .iter() + .fold([f32::MAX, f32::MAX, f32::MIN, f32::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]); + (e[0] <= e[2] && e[1] <= e[3]).then_some(e) +} + +/// The callout's line a label would have where it is now, as offsets from its `x`, `y`: from its +/// feature (`collide.callout_anchor`) to its text's nearest edge, when its class has callouts. +/// For showing a label being moved by hand. +pub fn callout_to(l: &PlacedLabel) -> Option<[f32; 4]> { + let [ax, ay] = l.collide.callout_anchor.filter(|_| l.collide.callout_reach > 0.0)?; + let line = callout_line(&label_boxes(l).0, [l.x + ax, l.y + ay])?; + Some([line[0] - l.x, line[1] - l.y, line[2] - l.x, line[3] - l.y]) +} + +/// A callout's line, from its feature at `anchor` to the nearest edge of the text's boxes; none +/// when the text covers the feature. +fn callout_line(text: &[[f32; 4]], anchor: [f32; 2]) -> Option<[f32; 4]> { + let e = boxes_extent(text)?; + if !anchor.iter().all(|v| v.is_finite()) { + return None; + } + let to = [anchor[0].clamp(e[0], e[2]), anchor[1].clamp(e[1], e[3])]; + ((to[0] - anchor[0]).hypot(to[1] - anchor[1]) >= 1.0).then_some([anchor[0], anchor[1], to[0], to[1]]) +} + +/// Where a crowded label with a callout goes: the nearest free spot for its text's boxes, inside +/// the view, whose line from its feature at `anchor` to the nearest edge of its text is at most +/// `reach` long (points), and which doesn't cover the feature. The offset that moves it there and +/// that line (from, to), or none. +fn callout_spot( + text: &[[f32; 4]], + anchor: [f32; 2], + reach: f32, + free: impl Fn(&[f32; 4]) -> bool, + w: f32, + h: f32, + spots: &mut usize, +) -> Option<(f32, f32, [f32; 4])> { + let ext = boxes_extent(text)?; + if !(anchor[0].is_finite() && anchor[1].is_finite() && reach.is_finite()) { + return None; + } + // Out in rings half the text's height apart, as far as its line may reach past its size: at + // most 60 of them, farther apart for a long reach, so the last still gets that far. + let far = reach + (ext[2] - ext[0]).max(ext[3] - ext[1]); + let step = ((ext[3] - ext[1]) * 0.5).clamp(2.0, 20.0).max(far / 60.0); + let rings = ((far / step).ceil() as usize).clamp(1, 60); + for ring in 1..=rings { + let d = ring as f32 * step; + // More directions farther out, so the spots stay about as close together. + let n = ((d * 0.75) as usize).clamp(8, 64); + let mut best: Option<(f32, f32, f32, [f32; 4])> = None; + for k in 0..n { + let a = k as f32 / n as f32 * std::f32::consts::TAU; + let (dx, dy) = (d * a.cos(), d * a.sin()); + let moved = |b: &[f32; 4]| [b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy]; + let e = moved(&ext); + if e[0] < 0.0 || e[1] < 0.0 || e[2] > w || e[3] > h { + continue; + } + // The line ends at the text's nearest edge; text over the feature hides it. + let to = [anchor[0].clamp(e[0], e[2]), anchor[1].clamp(e[1], e[3])]; + let len = (to[0] - anchor[0]).hypot(to[1] - anchor[1]); + if len > reach || len < 1.0 || best.is_some_and(|b| len >= b.0) { + continue; + } + if *spots == 0 { + return best.map(|(_, dx, dy, line)| (dx, dy, line)); + } + *spots -= 1; + if !text.iter().all(|b| free(&moved(b))) { + continue; + } + best = Some((len, dx, dy, [anchor[0], anchor[1], to[0], to[1]])); + } + if let Some((_, dx, dy, line)) = best { + return Some((dx, dy, line)); + } + } + None +} + /// Greedy collision-free placement, highest priority first. Locked labels are all placed (they /// were placed without collisions when the lock was taken). /// @@ -5065,6 +5231,7 @@ fn place_labels_with_previews( } let mut out = Placement { labels: fixed, unplaced: Vec::new(), keys: Vec::new(), line_anchors: Vec::new() }; let outside = |b: &[f32; 4]| b[2] < 0.0 || b[3] < 0.0 || b[0] > w || b[1] > h; + let (mut searches, mut spots) = (MAX_CALLOUT_SEARCHES, MAX_CALLOUT_SPOTS); // Same work bound as 20,000 heads with eight positions, shared across all bands. let mut layouts_left: usize = 20_000 * 8; let mut placed_groups = std::collections::HashSet::new(); @@ -5074,6 +5241,8 @@ fn place_labels_with_previews( // Siblings are contiguous: hold just one temporary preview until the group finishes. let mut pending = None; for Candidate { label: mut l, key, alternatives, curves, line, .. } in std::iter::once(head).chain(fallbacks) { + let c = l.collide; + let feature = c.callout_anchor.map(|[ax, ay]| [l.x + ax, l.y + ay]); let grouped = line.as_ref().is_some_and(|line| line.deduplicate); if grouped && placed_groups.contains(&key) { continue; @@ -5119,6 +5288,13 @@ fn place_labels_with_previews( continue; } place.swap(&mut l); + if locked + && !l.collide.pinned + && let Some([dx, dy]) = l.collide.callout_offset + { + l.x += dx; + l.y += dy; + } let (text, icon) = label_boxes(&l); if text.iter().chain(&icon).all(outside) { continue; @@ -5132,8 +5308,50 @@ fn place_labels_with_previews( break; } } - let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; + let Some((i, mut place, mut text, icon, mut show_text, show_icon)) = at else { continue }; place.swap(&mut l); + l.collide.callout = None; + let relative = |l: &PlacedLabel, line: [f32; 4]| [line[0] - l.x, line[1] - l.y, line[2] - l.x, line[3] - l.y]; + let mut moved_by = None; + if let (true, Some([dx, dy])) = (locked && !collide.pinned, collide.callout_offset) { + // Locked after a callout moved it: there again, with its line. + moved_by = Some([dx, dy]); + if let Some(f) = feature.filter(|_| collide.callout_reach > 0.0) + && let Some(line) = callout_line(&text, f) + { + l.collide.callout = Some(relative(&l, line)); + } + } else if collide.pinned { + // Moved by hand: where it was put, a callout's line back to its feature when its class + // has callouts (however long: it was put there). + if let Some(f) = feature.filter(|_| collide.callout_reach > 0.0) + && let Some(line) = callout_line(&text, f) + { + l.collide.callout = Some(relative(&l, line)); + } + } else if let Some(f) = feature + && collide.callout_reach > 0.0 + && !locked + && !has_icon + && has_text + && text.iter().any(|b| grid.hits(b)) + && searches > 0 + { + // A callout: crowded where it is and at its alternatives, the label moves to the + // nearest free spot within its reach, a line drawn back to its feature. With no such + // spot it stays, and is left out, or shown over the others when its class shows all. + searches -= 1; + if let Some((dx, dy, line)) = callout_spot(&text, f, collide.callout_reach, |b| !grid.hits(b), w, h, &mut spots) { + moved_by = Some([dx, dy]); + l.x += dx; + l.y += dy; + for b in &mut text { + *b = [b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy]; + } + show_text = true; + l.collide.callout = Some(relative(&l, line)); + } + } if !show_text && !show_icon { if show_unplaced && has_text && unplaced.len() < 2000 && pending.is_none() { pending = @@ -5160,7 +5378,7 @@ fn place_labels_with_previews( out.line_anchors.push((key, anchor)); } out.labels.push(l); - out.keys.push((key, u8::try_from(i).unwrap_or(u8::MAX))); + out.keys.push((key, rustgis_map::LockedPlace { place: u8::try_from(i).unwrap_or(u8::MAX), callout: moved_by })); } if let Some((key, preview)) = pending && key.is_none_or(|k| !placed_groups.contains(&k)) @@ -5178,7 +5396,8 @@ fn place_labels_with_previews( fn shows(l: &PlacedLabel, text: &[[f32; 4]], icon: &[[f32; 4]], grid: &LabelGrid, locked: bool) -> (bool, bool) { let c = l.collide; let (has_text, has_icon) = (!text.is_empty(), !icon.is_empty()); - let fits = |boxes: &[[f32; 4]], overlap: bool| locked || overlap || !boxes.iter().any(|b| grid.hits(b)); + // A label moved by hand shows where it was put, whatever is under it. + let fits = |boxes: &[[f32; 4]], overlap: bool| locked || overlap || c.pinned || !boxes.iter().any(|b| grid.hits(b)); let mut show_text = has_text && fits(text, c.text_allow_overlap); let mut show_icon = has_icon && fits(icon, c.icon_allow_overlap); // Which part may show without the other (MapLibre's rules). @@ -5444,6 +5663,128 @@ mod tests { } } + /// While a label is dragged, its callout's line is worked out where it is: from its feature, + /// which stays put, to its text's nearest edge; none when its class has no callouts, or its + /// text covers its feature. Its extent is what picks it. + #[test] + fn a_dragged_label_s_callout_follows_it() { + use rustgis_tiles::Collide; + let l = PlacedLabel { + y: 50.0, + collide: Collide { callout_reach: 40.0, callout_anchor: Some([-60.0, 30.0]), ..Default::default() }, + ..label("Moved", 150.0, None, 0.0) + }; + let e = label_extent(&l).unwrap(); + assert!(e[0] < 150.0 && 150.0 < e[2] && e[1] < 50.0 && 50.0 < e[3], "{e:?}"); + let [x0, y0, x1, y1] = callout_to(&l).expect("a line"); + assert_eq!((x0, y0), (-60.0, 30.0), "from the feature"); + // To the text's bottom-left corner: the feature is below and to the left of it. + assert!(((l.x + x1) - e[0]).abs() < 1e-3 && ((l.y + y1) - e[3]).abs() < 1e-3); + let none = PlacedLabel { collide: Collide { callout_reach: 0.0, ..l.collide }, ..l.clone() }; + assert!(callout_to(&none).is_none(), "no callouts"); + let over = PlacedLabel { collide: Collide { callout_anchor: Some([0.0, 0.0]), ..l.collide }, ..l.clone() }; + assert!(callout_to(&over).is_none(), "the text over its feature"); + } + + /// A label moved by hand is placed where it was put, over another and first, so the others keep + /// off it; with callouts its line reaches back to its feature, however far it went. + #[test] + fn labels_moved_by_hand_are_placed_where_they_were_put() { + use rustgis_tiles::Collide; + // Its feature 80 points left of where it was put. + let pinned = |callout: f32| PlacedLabel { + y: 50.0, + collide: Collide { pinned: true, callout_reach: callout, callout_anchor: Some([-80.0, 0.0]), ..Default::default() }, + ..label("Moved", 150.0, None, 0.0) + }; + let other = PlacedLabel { y: 50.0, ..label("Other", 150.0, None, 0.0) }; + // The other first by priority, the moved one placed first anyway: it keeps its spot. + let p = place_labels( + vec![(other.clone(), 9.0, (u32::MAX, 0, 0, 0)), (pinned(0.0), 1e15, (u32::MAX, 1, 0, 0))], + Vec::new(), + 300.0, + 120.0, + false, + ); + assert_eq!(p.labels.first().map(|l| (l.text.as_str(), l.x, l.y)), Some(("Moved", 150.0, 50.0))); + assert_eq!(p.labels.len(), 1, "the other keeps off it"); + assert!(p.labels[0].collide.callout.is_none(), "no callouts: no line"); + // With callouts: a line from the feature to its text, past any callout's usual reach. + let p = place_labels(vec![(pinned(5.0), 1e15, (u32::MAX, 1, 0, 0))], Vec::new(), 300.0, 120.0, false); + let l = &p.labels[0]; + let [x0, y0, x1, y1] = l.collide.callout.expect("a line back to its feature"); + assert!(((l.x + x0) - 70.0).abs() < 1e-3 && ((l.y + y0) - 50.0).abs() < 1e-3); + assert!((x1 - x0).hypot(y1 - y0) > 5.0); + // And over whatever is there when it's placed after: a fixed label under it stays too. + let fixed = vec![other]; + let p = place_labels(vec![(pinned(0.0), 1e15, (u32::MAX, 1, 0, 0))], fixed, 300.0, 120.0, false); + assert_eq!(p.labels.len(), 2); + } + + /// A label crowded off its spot moves, with a callout, to the nearest free spot whose line back + /// to its feature is no longer than its reach, and keeps that line; with every label shown it + /// stays, over the other; with neither it's left out. + #[test] + fn callouts_move_crowded_labels_and_show_all_overlaps_them() { + use rustgis_tiles::Collide; + // Two labels on one spot, each by its feature 10 points below the text's centre. + let at = |text: &str, collide: Collide| PlacedLabel { + y: 50.0, + collide: Collide { callout_anchor: Some([0.0, 10.0]), ..collide }, + ..label(text, 100.0, None, 0.0) + }; + let place = |second: Collide| { + let cands = vec![(at("First", Collide::default()), 2.0, (u32::MAX, 0, 0, 0)), (at("Second", second), 1.0, (u32::MAX, 1, 0, 0))]; + place_labels(cands, Vec::new(), 300.0, 120.0, false) + }; + let overlap = |a: &[f32; 4], b: &[f32; 4]| a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]; + // Neither: the second is left out. + let p = place(Collide::default()); + assert_eq!((p.labels.len(), p.unplaced.len()), (1, 1)); + // A callout: moved clear of the first, a line from its feature to its text's edge. + let p = place(Collide { callout_reach: 40.0, ..Default::default() }); + assert_eq!(p.labels.len(), 2); + let (first, second) = (&p.labels[0], &p.labels[1]); + assert!(first.collide.callout.is_none(), "in its own spot, no line"); + let [x0, y0, x1, y1] = second.collide.callout.expect("a callout's line"); + assert!(((second.x + x0) - 100.0).abs() < 1e-3 && ((second.y + y0) - 60.0).abs() < 1e-3, "it starts at the feature"); + let len = (x1 - x0).hypot(y1 - y0); + assert!((1.0..=40.0).contains(&len), "{len} points"); + let (a, b) = (label_boxes(first).0, label_boxes(second).0); + assert!(!a.iter().any(|x| b.iter().any(|y| overlap(x, y))), "clear of the first"); + // No free spot within so short a reach: left out, as without one. + let p = place(Collide { callout_reach: 1.0, ..Default::default() }); + assert_eq!(p.labels.len(), 1); + // Every label shown: the second stays in its spot, over the first, with no line. + let p = place(Collide { text_allow_overlap: true, ..Default::default() }); + assert_eq!(p.labels.len(), 2); + assert_eq!((p.labels[1].x, p.labels[1].y), (100.0, 50.0)); + assert!(p.labels[1].collide.callout.is_none()); + // Both: a free spot nearby comes before overlapping. + let p = place(Collide { callout_reach: 40.0, text_allow_overlap: true, ..Default::default() }); + assert!(p.labels[1].collide.callout.is_some()); + } + + /// A callout's line is drawn from its feature to the text, in the label's colour. + #[test] + fn a_callout_line_is_drawn() { + use rustgis_tiles::Collide; + let mut pm = Canvas::new(200, 100).unwrap(); + pm.fill(tiny_skia::Color::WHITE); + let l = PlacedLabel { + y: 20.0, + color: Rgba::rgb(200, 0, 0), + collide: Collide { callout: Some([-60.0, 60.0, -10.0, 10.0]), ..Default::default() }, + ..label("Callout", 150.0, None, 0.0) + }; + text::draw(&mut pm, &l, 1.0); + // Halfway along the line (x + y = 170), well clear of the text: red. (The pixel whose + // centre is on it: the line passes through (115, 55)'s corner.) + let mid = pm.pixels().pixel(115, 54).unwrap(); + assert!(mid.red() > 120 && mid.green() < 80 && mid.blue() < 80, "{mid:?}"); + assert!(pm.pixels().pixel(115, 20).is_some_and(|p| p.green() > 200), "nothing above the line"); + } + /// A halo of its own width reaches past the letters, so its label's collision box grows by it: /// another label's text never falls under it. The thin halo and none leave the box as it was. #[test] @@ -5624,7 +5965,8 @@ mod tests { // The first free one, past one that is taken too. let p = place_labels(vec![blocker(), taken(vec![(104.0, 20.0), (300.0, 20.0), (300.0, 100.0)])], Vec::new(), 400.0, 200.0, false); assert_eq!(at(&p)[1], ("Taken".to_string(), 300.0, 20.0)); - assert_eq!(p.keys, [((u32::MAX, 0, 0, 0), 0), ((1, 0, 0, 0), 2)], "the place it took, for a lock"); + let place = |place| rustgis_map::LockedPlace { place, callout: None }; + assert_eq!(p.keys, [((u32::MAX, 0, 0, 0), place(0)), ((1, 0, 0, 0), place(2))], "the place it took, for a lock"); assert!(p.unplaced.is_empty()); // All taken: unplaced once, where it would have gone. let p = place_labels(vec![blocker(), taken(vec![(104.0, 20.0), (96.0, 22.0)])], Vec::new(), 400.0, 200.0, false); @@ -5660,7 +6002,7 @@ mod tests { let blocker = label("Blocker", 100.0, None, 0.0); let p = place_labels(vec![candidate.clone()], vec![blocker], 400.0, 200.0, false); assert_eq!(LabelPlace::of(&p.labels[1]), other); - assert_eq!(p.keys, [((1, 0, 0, 0), 1)]); + assert_eq!(p.keys, [((1, 0, 0, 0), rustgis_map::LockedPlace { place: 1, callout: None })]); // If every place collides, View Unplaced retains the original geometry, once. let p = place_labels( vec![candidate], @@ -6011,6 +6353,164 @@ mod tests { assert!(starfield(1e9).is_some_and(|t| t.width() == 2048)); } + /// A label moved by hand is drawn where it was put, on the flat map: its place is kept as + /// longitude and latitude, and drawn through the map's projection as its feature is. + /// (Taken for the projection's own coordinates, it was drawn off by Null Island instead.) + #[test] + fn a_label_moved_by_hand_is_drawn_where_it_was_put() { + let mut m = Map::new("Map"); + let mut d = Dataset::new("pts", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Point(Coord::new(2.35, 48.85))), vec![Value::Text("Paris".into())]); + d.push(Some(Geometry::Point(Coord::new(4.83, 45.76))), vec![Value::Text("Lyon".into())]); + let id = m.add_dataset(d); + let paris = m.layer(id).unwrap().dataset.features.first().unwrap().id; + if let Some(l) = m.layer_mut(id) { + l.labels.enabled = true; + l.labels.default.callout = Some(40.0); + assert!(l.labels.default.move_label(paris, Some([5.0, 49.5]))); + } + let camera = Camera { center: lonlat_to_merc(Coord::new(3.6, 47.3)), res: 5000.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera, sky: false }; + let on_screen = |m: &Map, lon: f64, lat: f64| view.to_screen(m.to_map(Coord::new(lon, lat)).unwrap()); + let labels = render(&mut m, &view).unwrap().labels; + let l = labels.iter().find(|l| l.text == "Paris").expect("Paris's label is drawn"); + let (ex, ey) = on_screen(&m, 5.0, 49.5); + assert!((f64::from(l.x) - ex).abs() < 0.5 && (f64::from(l.y) - ey).abs() < 0.5, "at ({}, {}), not ({ex}, {ey})", l.x, l.y); + assert!(l.collide.pinned); + assert_eq!(l.collide.source, Some((id, paris, 0)), "what the Move Label tool grabs it by"); + // Its callout's line starts at Paris. + let [x0, y0, ..] = l.collide.callout.expect("a line back to Paris"); + let (px, py) = on_screen(&m, 2.35, 48.85); + assert!((f64::from(l.x + x0) - px).abs() < 0.5 && (f64::from(l.y + y0) - py).abs() < 0.5); + // Lyon's, not moved, is where its class puts it, with no line. + let lyon = labels.iter().find(|l| l.text == "Lyon").unwrap(); + assert!(!lyon.collide.pinned && lyon.collide.callout.is_none()); + } + + /// A label moved by hand into view is drawn whatever its feature's place: a point's, or a + /// line's labelled along it, out of view, its callout's line reaching back off the screen. + #[test] + fn a_label_moved_into_view_is_drawn_with_its_feature_out_of_it() { + let mut m = Map::new("Map"); + let mut d = Dataset::new("pts", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Point(Coord::new(2.35, 48.85))), vec![Value::Text("Paris".into())]); + let pts = m.add_dataset(d); + let mut d = Dataset::new("lines", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(1.0, 44.0), Coord::new(3.0, 44.0)]])), vec![Value::Text("Garonne".into())]); + let lines = m.add_dataset(d); + for (id, to) in [(pts, [20.0, 50.0]), (lines, [20.0, 47.0])] { + let fid = m.layer(id).unwrap().dataset.features.first().unwrap().id; + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.callout = Some(40.0); + if id == lines { + l.labels.default.placement = LabelPlacement::Parallel; + } + assert!(l.labels.default.move_label(fid, Some(to))); + } + // Over Central Europe: France is off the left of the view. + let camera = Camera { center: lonlat_to_merc(Coord::new(20.0, 48.5)), res: 2000.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera, sky: false }; + let on_screen = |m: &Map, lon: f64, lat: f64| view.to_screen(m.to_map(Coord::new(lon, lat)).unwrap()); + assert!(on_screen(&m, 2.35, 48.85).0 < 0.0 && on_screen(&m, 3.0, 44.0).0 < 0.0, "the features are out of view"); + let labels = render(&mut m, &view).unwrap().labels; + for (text, at) in [("Paris", (20.0, 50.0)), ("Garonne", (20.0, 47.0))] { + let l = labels.iter().find(|l| l.text == text).unwrap_or_else(|| panic!("{text}'s label is drawn")); + let (ex, ey) = on_screen(&m, at.0, at.1); + assert!((f64::from(l.x) - ex).abs() < 0.5 && (f64::from(l.y) - ey).abs() < 0.5, "{text}"); + assert!(l.collide.pinned && l.angle.is_none() && l.glyphs.is_empty(), "{text}: upright where it was put"); + let [x0, ..] = l.collide.callout.unwrap_or_else(|| panic!("{text}: a line back to its feature")); + assert!(l.x + x0 < 0.0, "{text}: its line starts off the screen"); + } + } + + /// A long callout is looked for as far as it may reach (the search's rings spaced out to get + /// there), not only near the label. + #[test] + fn a_long_callout_reaches_a_far_free_spot() { + // A small text by its feature at (100, 100); only spots 200 points right of it are free. + let text = [[95.0, 98.0, 105.0, 102.0]]; + let free = |b: &[f32; 4]| b[0] > 300.0; + let (dx, _, [x0, y0, x1, y1]) = callout_spot(&text, [100.0, 100.0], 300.0, free, 1000.0, 200.0, &mut 100_000).expect("a spot"); + assert!(dx > 195.0); + let len = (x1 - x0).hypot(y1 - y0); + assert!((150.0..=300.0).contains(&len), "{len} points"); + // Beyond its reach, none. + assert!(callout_spot(&text, [100.0, 100.0], 100.0, free, 1000.0, 200.0, &mut 100_000).is_none()); + // The placement's spots used up: it stops looking, and says how many it tried. + let mut spots = 5; + assert!(callout_spot(&text, [100.0, 100.0], 300.0, free, 1000.0, 200.0, &mut spots).is_none()); + assert_eq!(spots, 0); + } + + /// Two points close together: the second label moves off by a callout. Locked, it keeps that + /// place and its line, as the map moves too (it went back onto the first, without its line). + #[test] + fn a_lock_keeps_a_label_where_its_callout_moved_it() { + let mut m = Map::new("Map"); + let mut d = Dataset::new("pts", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![Value::Text("First place".into())]); + d.push(Some(Geometry::Point(Coord::new(0.01, 0.0))), vec![Value::Text("Second place".into())]); + let id = m.add_dataset(d); + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.callout = Some(60.0); + let at = |x: f64| { + let camera = Camera { center: lonlat_to_merc(Coord::new(x, 0.0)), res: 500.0, bearing: 0.0, pitch: 0.0 }; + View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera, sky: false } + }; + let placed = |m: &mut Map, view: &View| { + let mut labels = render(m, view).unwrap().labels; + labels.sort_by(|a, b| a.text.cmp(&b.text)); + labels + }; + let free = placed(&mut m, &at(0.0)); + assert_eq!(free.len(), 2); + let line = free.iter().filter_map(|l| l.collide.callout).next().expect("one moved off by a callout"); + // Locked: the same places and line, frame after frame. + m.label_display.locked = true; + for _ in 0..2 { + let locked = placed(&mut m, &at(0.0)); + assert_eq!(locked.len(), 2); + for (a, b) in free.iter().zip(&locked) { + assert!((a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() < 0.01, "{}: ({}, {}) to ({}, {})", a.text, a.x, a.y, b.x, b.y); + } + assert_eq!(locked.iter().filter_map(|l| l.collide.callout).next(), Some(line)); + } + // The map panned: the two labels stay as far apart, the line the same. + let panned = placed(&mut m, &at(0.02)); + assert_eq!(panned.len(), 2); + let apart = |l: &[PlacedLabel]| (l[1].x - l[0].x, l[1].y - l[0].y); + let (a, b) = (apart(&free), apart(&panned)); + assert!((a.0 - b.0).abs() < 0.5 && (a.1 - b.1).abs() < 0.5, "{a:?} {b:?}"); + assert_eq!(panned.iter().filter_map(|l| l.collide.callout).next(), Some(line)); + } + + /// On a turned or tilted map a callout's line starts at its feature there too, not where the + /// north-up frame had it. + #[test] + fn a_callout_starts_at_its_feature_on_a_turned_map() { + let mut m = Map::new("Map"); + let mut d = Dataset::new("pts", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Point(Coord::new(2.35, 48.85))), vec![Value::Text("Paris".into())]); + let id = m.add_dataset(d); + let fid = m.layer(id).unwrap().dataset.features.first().unwrap().id; + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.callout = Some(40.0); + assert!(l.labels.default.move_label(fid, Some([3.5, 49.5]))); + for (bearing, pitch) in [(40.0, 0.0), (0.0, 40.0), (40.0, 30.0)] { + let camera = Camera { center: lonlat_to_merc(Coord::new(2.9, 49.1)), res: 1000.0, bearing, pitch }; + let view = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera, sky: false }; + let labels = render(&mut m, &view).unwrap().labels; + let l = labels.iter().find(|l| l.text == "Paris").expect("drawn"); + let [x0, y0, ..] = l.collide.callout.expect("a line back to Paris"); + let (px, py) = view.to_screen(m.to_map(Coord::new(2.35, 48.85)).unwrap()); + let off = (f64::from(l.x + x0) - px).hypot(f64::from(l.y + y0) - py); + assert!(off < 1.0, "bearing {bearing}, pitch {pitch}: {off} points off"); + } + } + /// Each label class labels every feature its clause takes, as in ArcGIS Pro (#732): a feature /// two classes take gets a label from each, the class listed first placed first. A hidden /// class labels none, and a clause that no longer reads takes none (#618). @@ -6191,7 +6691,7 @@ mod tests { // Locked, it stays there as the map moves. m.label_display.locked = true; assert_eq!(labels(&mut m, &flat), placed); - assert!(m.label_lock.as_ref().is_some_and(|k| k.iter().any(|(_, i)| *i > 0)), "{:?}", m.label_lock); + assert!(m.label_lock.as_ref().is_some_and(|k| k.iter().any(|(_, i)| i.place > 0)), "{:?}", m.label_lock); let moved = labels(&mut m, &view(0.0, 0.0, Coord::new(2.6, 48.85))); let apart = |l: &[(f32, f32, f32)]| (l[1].1 - l[0].1, l[1].2 - l[0].2); assert_eq!(moved.len(), 2); diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 978549aae..0578d6d50 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -34,12 +34,12 @@ fn curves_are_laid_out_only_until_a_place_fits() { let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); label_places::LAYOUTS.with(|n| n.set(0)); let p = place_labels(vec![c.clone()], Vec::new(), 400.0, 200.0, false); - assert_eq!(p.keys[0].1, 0); + assert_eq!(p.keys[0].1.place, 0); label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 1)); let blocker = PlacedLabel { y: 100.0, ..label("BLOCKER", 200.0, None, 0.0) }; label_places::LAYOUTS.with(|n| n.set(0)); let p = place_labels(vec![c], vec![blocker], 400.0, 200.0, false); - let won = p.keys[0].1; + let won = p.keys[0].1.place; assert!(won > 0 && won < 8); label_places::LAYOUTS.with(|n| assert_eq!(n.get(), usize::from(won) + 1)); assert_eq!(p.labels[1].glyphs.len(), 4); @@ -50,7 +50,7 @@ fn a_sharp_middle_tries_a_straight_stretch_and_a_lock_replays_that_index() { let c = curved(&[(0.0, 100.0), (200.0, 100.0), (200.0, 300.0)]); assert!(c.curves.as_ref().unwrap().place(0, &mut c.label.clone()).is_none()); let p = place_labels(vec![c.clone()], Vec::new(), 400.0, 400.0, false); - let won = usize::from(p.keys[0].1); + let won = usize::from(p.keys[0].1.place); assert!(won > 0); let mut locked = c; locked.curves.as_mut().unwrap().locked = Some(won); @@ -58,7 +58,7 @@ fn a_sharp_middle_tries_a_straight_stretch_and_a_lock_replays_that_index() { turn_candidate(&mut locked, |x, y| Some((x + 12.0, y + 7.0))); label_places::LAYOUTS.with(|n| n.set(0)); let q = place_labels(vec![locked], Vec::new(), 450.0, 450.0, true); - assert_eq!(usize::from(q.keys[0].1), won); + assert_eq!(usize::from(q.keys[0].1.place), won); assert!((q.labels[0].x - p.labels[0].x - 12.0).abs() < 1e-4); assert!((q.labels[0].y - p.labels[0].y - 7.0).abs() < 1e-4); assert_eq!(q.labels[0].glyphs, p.labels[0].glyphs); @@ -143,7 +143,7 @@ fn rendering_retries_only_opted_in_nonrepeated_line_labels() { map.label_display.locked = true; let first = render(&mut map, &view).unwrap(); let key = map.label_lock.as_ref().unwrap().iter().find(|(k, _)| k.0 == id).copied().unwrap(); - assert!(key.1 > 0); + assert!(key.1.place > 0); let panned = View { camera: Camera { center: Coord::new(7500.0, 0.0), ..view.camera }, ..view }; let next = render(&mut map, &panned).unwrap(); let x = |r: &Rendered| r.labels.iter().find(|l| l.text == "road").unwrap().x; @@ -289,7 +289,7 @@ fn a_projected_anchor_does_not_keep_unprojectable_characters() { let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); let c = tilt_candidate(c, &|x, y| (x > 195.0).then_some((x, y))).unwrap(); let p = place_labels(vec![c], Vec::new(), 400.0, 200.0, false); - assert_eq!(p.keys[0].1, 2, "middle anchor is visible, but its leftmost glyphs are not"); + assert_eq!(p.keys[0].1.place, 2, "middle anchor is visible, but its leftmost glyphs are not"); assert!(p.labels[0].glyphs.iter().all(|g| p.labels[0].x + g.dx > 195.0)); } @@ -381,7 +381,7 @@ fn turned_candidates_skip_failed_alternatives_without_renumbering_slots() { c.alternatives = vec![(200.0, 20.0).into(), (300.0, 20.0).into()]; turn_candidate(&mut c, |x, y| (x != 200.0).then_some((x + 50.0, y))); let p = place_labels(vec![c], vec![label("BLOCKER", 150.0, None, 0.0)], 400.0, 200.0, false); - assert_eq!(p.keys[0].1, 2); + assert_eq!(p.keys[0].1.place, 2); assert_eq!(p.labels[1].x, 350.0); } @@ -519,7 +519,7 @@ fn a_place_without_a_back_projected_anchor_is_never_locked() { c.alternatives = vec![LabelPlace::from((300.0, 100.0))]; c.line = Some(LinePlaces { anchors: vec![None, Some(Coord::new(2.0, 0.0))], ..Default::default() }); let placed = place_labels(vec![c.clone()], Vec::new(), 400.0, 200.0, false); - assert_eq!(placed.keys, vec![((1, 0, 0, 0), 1)]); + assert_eq!(placed.keys, vec![((1, 0, 0, 0), rustgis_map::LockedPlace { place: 1, callout: None })]); assert_eq!(placed.line_anchors, vec![((1, 0, 0, 0), Coord::new(2.0, 0.0))]); c.alternatives.clear(); c.line.as_mut().unwrap().anchors = vec![None]; @@ -534,7 +534,7 @@ fn invalid_straight_slots_keep_their_matching_anchor() { c.line = Some(LinePlaces { anchors: vec![Some(Coord::new(0.0, 0.0)), None, Some(Coord::new(2.0, 0.0))], ..Default::default() }); let blocker = PlacedLabel { y: 100.0, ..label("BLOCKER", 100.0, None, 0.0) }; let placed = place_labels(vec![c], vec![blocker], 400.0, 200.0, false); - assert_eq!(placed.keys, vec![((1, 0, 0, 0), 2)]); + assert_eq!(placed.keys, vec![((1, 0, 0, 0), rustgis_map::LockedPlace { place: 2, callout: None })]); assert_eq!(placed.line_anchors, vec![((1, 0, 0, 0), Coord::new(2.0, 0.0))]); } @@ -586,7 +586,7 @@ fn an_invalid_locked_straight_slot_never_invents_a_middle_preview() { assert!(!anchors.is_empty()); // A stale or malformed lock cannot name a real place. There is no accurate preview // to draw, even when View Unplaced is on; the valid lock's geographic anchor stays put. - map.label_lock.as_mut().unwrap()[0].1 = u8::MAX; + map.label_lock.as_mut().unwrap()[0].1.place = u8::MAX; let r = render(&mut map, &view).unwrap(); assert!(r.labels.is_empty()); assert!(r.unplaced.is_empty()); @@ -618,7 +618,7 @@ fn an_invalid_locked_curved_slot_never_invents_a_middle_preview() { render(&mut map, &view).unwrap(); assert!(!map.label_lock.as_ref().unwrap().is_empty()); let anchors = map.label_lock_line_anchors.clone(); - map.label_lock.as_mut().unwrap()[0].1 = u8::MAX; + map.label_lock.as_mut().unwrap()[0].1.place = u8::MAX; let r = render(&mut map, &view).unwrap(); assert!(r.labels.is_empty()); assert!(r.unplaced.is_empty()); diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index 674d4e07c..3f68e16ed 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -1001,7 +1001,7 @@ fn pitched_plain_chunks_bypass_depth_budget_alone_and_with_upright_markers() { for layer in &mut failed.layers { *layer = streamed(layer, if layer.id == ground { 0 } else { 1 }); } - let lock = Some(vec![((ground, 0, 0, 0), 0)]); + let lock = Some(vec![((ground, 0, 0, 0), rustgis_map::LockedPlace::default())]); failed.label_lock = lock.clone(); assert!(render_with_backend(&mut failed, &view, true, backend).is_none()); assert_eq!(failed.label_lock, lock); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 227a53df4..72c35cee6 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -619,6 +619,7 @@ fn slanted_glyph(pm: &mut Canvas, og: &ab_glyph::OutlinedGlyph, base: f32, color /// Draws a placed label (logical coordinates) with its halo. A label turned with the map (one /// along a street) is drawn upright into a picture of its own, which is turned into place. pub fn draw(pm: &mut Canvas, l: &PlacedLabel, ppp: f32) { + draw_callout(pm, l, ppp); // Curved along a line: each character turned into its place, every character's halo // first, so none covers the letter before it. if !l.glyphs.is_empty() { @@ -639,6 +640,28 @@ pub fn draw(pm: &mut Canvas, l: &PlacedLabel, ppp: f32) { } } +/// A label's callout line, from its feature to the edge of its text, under the text (and its +/// halo under the line, so it shows over the map). +fn draw_callout(pm: &mut Canvas, l: &PlacedLabel, ppp: f32) { + let Some([x0, y0, x1, y1]) = l.collide.callout else { return }; + let mut pb = tiny_skia::PathBuilder::new(); + pb.move_to((l.x + x0) * ppp, (l.y + y0) * ppp); + pb.line_to((l.x + x1) * ppp, (l.y + y1) * ppp); + let Some(path) = pb.finish() else { return }; + let width = (CALLOUT_WIDTH * ppp).max(0.5); + let inks = l.halo.map(|h| (h, width + 2.0 * ppp)).into_iter().chain([(l.color, width)]); + for (c, w) in inks { + let mut paint = Paint::default(); + paint.set_color_rgba8(c.r(), c.g(), c.b(), c.a()); + paint.anti_alias = true; + let stroke = tiny_skia::Stroke { width: w, line_cap: tiny_skia::LineCap::Round, ..Default::default() }; + pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } +} + +/// How wide a callout's line is (points). +pub const CALLOUT_WIDTH: f32 = 0.9; + /// What of a label to draw: its halo, its text, or both (the halo under the text). #[derive(Clone, Copy, PartialEq)] enum Ink { diff --git a/crates/tiles/src/symbols.rs b/crates/tiles/src/symbols.rs index 95b850146..53c7161da 100644 --- a/crates/tiles/src/symbols.rs +++ b/crates/tiles/src/symbols.rs @@ -88,6 +88,24 @@ pub struct Collide { /// Others may be placed over it. pub text_ignore_placement: bool, pub icon_ignore_placement: bool, + /// With a callout (a vector layer's label class): crowded where it is, the label may move to + /// a free spot whose line back to its feature is at most this long (points); 0 stays put. + pub callout_reach: f32, + /// Its feature's place, where a callout's line starts: an offset (points) from where the + /// label is drawn (`x`, `y`), so it moves with the label until it's placed. + pub callout_anchor: Option<[f32; 2]>, + /// Once moved off its spot, its callout's line: from its feature to the edge of its text, as + /// offsets (points) from where the label is drawn (`x`, `y`), so it moves with the label. + pub callout: Option<[f32; 4]>, + /// Locked after a callout moved it: how far (points) it stays moved from its place, its line + /// drawn back to its feature. + pub callout_offset: Option<[f32; 2]>, + /// Moved by hand (the Move Label tool): placed where it was put, whatever is under it, and + /// placed first, so the others keep off it. + pub pinned: bool, + /// What it labels, for moving it by hand: its layer's id, its feature's id and its label + /// class (in the layer's labeling order). None for labels not of a layer's features. + pub source: Option<(u32, u64, u32)>, } impl Default for Collide { @@ -101,6 +119,12 @@ impl Default for Collide { icon_allow_overlap: false, text_ignore_placement: false, icon_ignore_placement: false, + callout_reach: 0.0, + callout_anchor: None, + callout: None, + callout_offset: None, + pinned: false, + source: None, } } } @@ -268,6 +292,7 @@ fn label_of(layer: &StyleLayer, ctx: &FeatureCtx, sheet: Option<&Sprite>, layer_ icon_allow_overlap: flag(layer, "icon-allow-overlap", ctx), text_ignore_placement: flag(layer, "text-ignore-placement", ctx), icon_ignore_placement: flag(layer, "icon-ignore-placement", ctx), + ..Collide::default() }; Some(Label { text, diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index f947ff30e..8f4e16baa 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -903,6 +903,7 @@ pub(crate) fn family_picker(ui: &mut Ui, salt: impl std::hash::Hash + std::fmt:: Picked { response, chosen } } +/// Edits this label class's placement, including overlap and callout options, through commands. fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { let cur = c.it.placement; let pick = |app: &mut RustGisApp, ui: &mut Ui, pl: LabelPlacement| { @@ -943,6 +944,65 @@ fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { } ui.add_space(6.0); offset(app, ui, l, c); + ui.add_space(6.0); + crowding(app, ui, l, c); +} + +/// Where labels crowd: every one shown, over the others, rather than some left out; and +/// callouts, which move a crowded label to a free spot nearby with a line back to its feature, +/// no longer than so far. +fn crowding(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { + use rustgis_engine::map::symbology::{CALLOUT_REACH, callout_reach}; + let id = l.id; + grid(ui, "crowding", |ui| { + let input_label = ui.label(tl!("Show all labels")); + let mut all = c.it.show_all; + if ui + .checkbox(&mut all, "") + .labelled_by(input_label.id) + .on_hover_text(tl!("Draw every label, over the others where they crowd, rather than leaving some out.")) + .changed() + { + run(app, "labels.placement", c.params(json!({"showAll": all}))); + } + ui.end_row(); + let input_label = ui.label(tl!("Callouts")); + ui.horizontal(|ui| { + let mut on = c.it.callout.is_some(); + if ui + .checkbox(&mut on, "") + .labelled_by(input_label.id) + .on_hover_text(tl!( + "Move a label crowded off its spot to a free one nearby, with a line back to its feature. Labels along lines stay." + )) + .changed() + { + run(app, "labels.placement", c.params(json!({"callout": on}))); + } + let Some(reach) = c.it.callout else { return }; + let mut v = f64::from(reach); + let r = ui + .add(egui::DragValue::new(&mut v).range(CALLOUT_REACH).speed(1.0).max_decimals(0).prefix(tl!("at most ")).suffix(" pt")) + .labelled_by(input_label.id) + .on_hover_text(tl!("The longest a callout's line may be.")); + crate::ribbon::live_drag( + app, + ui, + &r, + f64::from(reach), + v, + |app, x| { + if let Some(k) = app.session.map_mut().layer_mut(id).and_then(|l| class_mut(&mut l.labels, c.name)) { + k.callout = callout_reach(x); + } + }, + |app, x| { + run(app, "labels.placement", c.params(json!({"callout": x}))); + }, + ); + }); + ui.end_row(); + }); } /// Along a line, a label every so many points, spaced out from the one at its middle; off, the one diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index c67b36a99..00aaed669 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -54,6 +54,11 @@ enum DragKind { row: usize, origin: Pos2, }, + /// Moving a layer's label with the Move Label tool: where the press was. The label, and what + /// it labels, are in `MapView::moving_label`. + Label { + origin: Pos2, + }, } /// Graphic shapes drawn by dragging rather than clicking. @@ -251,6 +256,10 @@ pub struct MapView { /// Where on a tilted map's ground each label is (the camera's coordinates), so it stays there /// while the map moves. label_map: Vec>, + /// Where each label's callout starts (its feature), as `label_lonlat` and `label_map` keep the + /// labels: its line stays on its feature while the map moves. + callout_lonlat: Vec>, + callout_map: Vec>, /// The camera the labels were placed for. label_camera: Camera, /// The map revision whose GPU scene had only live tile layers (#635): tiles arriving draw @@ -262,6 +271,8 @@ pub struct MapView { view_px: [usize; 2], resized_at: f64, drag: Option<(Pos2, DragKind)>, + /// The label being moved with the Move Label tool. + moving_label: Option, sketch: Vec, selection_gesture: Option, radius_preview: Option<(Coord, f64, Vec>)>, @@ -366,12 +377,15 @@ impl Default for MapView { motion_cost: 0.0, labels: Vec::new(), label_lonlat: Vec::new(), + callout_lonlat: Vec::new(), + callout_map: Vec::new(), label_camera: Camera::world(), live_rev: None, last_interaction: -10.0, view_px: [0, 0], resized_at: -10.0, drag: None, + moving_label: None, sketch: Vec::new(), selection_gesture: None, radius_preview: None, @@ -494,6 +508,15 @@ impl MapView { self.selection_gesture = None; self.radius_preview = None; self.cancel_vertex_drag(); + self.cancel_label_drag(); + } + + /// Drops a label being moved with the Move Label tool, leaving it where it was. + pub(crate) fn cancel_label_drag(&mut self) { + self.moving_label = None; + if self.drag.is_some_and(|(_, kind)| matches!(kind, DragKind::Label { .. })) { + self.drag = None; + } } /// Cancels a vertex preview and its pending release together, leaving other map gestures @@ -833,6 +856,8 @@ fn main_view(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { app.map.labels.clear(); app.map.label_lonlat.clear(); app.map.label_map.clear(); + app.map.callout_lonlat.clear(); + app.map.callout_map.clear(); } let settling = (tilted || globe_view || tiled_flat) && !moving && app.ws.playing.is_none() && !hold; let mut tilt_draped = tilt_draped; @@ -1195,9 +1220,11 @@ fn main_view(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let placed = app.map.label_camera; if globe_view && (placed.center != cam.center || placed.res != cam.res) { let g = Globe::new(&cam, w, h); - for (l, c) in app.map.labels.iter().zip(&app.map.label_lonlat) { - if let Some((x, y)) = c.and_then(|c| g.forward(c)) { - draw_label(&painter, l, rect.min + vec2(x as f32, y as f32)); + let screen = |c: &Option| c.and_then(|c| g.forward(c)).map(|(x, y)| rect.min + vec2(x as f32, y as f32)); + for (i, (l, c)) in app.map.labels.iter().zip(&app.map.label_lonlat).enumerate() { + if let Some(at) = screen(c) { + let feature = app.map.callout_lonlat.get(i).and_then(screen); + draw_label(&painter, &callout_from(l, at, feature), at); } } } else if (tilt_draped || (app.map.gpu_frame.is_some() && !app.map.label_map.is_empty())) && placed != cam { @@ -1210,20 +1237,25 @@ fn main_view(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { cam.try_to_screen(m, w, h).map(|(x, y)| (x as f32, y as f32)) }; let turns = placed.bearing != cam.bearing || placed.pitch != cam.pitch; - for (l, m) in app.map.labels.iter().zip(&app.map.label_map) { - if let Some((x, y)) = m.and_then(|m| cam.try_to_screen(m, w, h)).filter(|s| sky.is_none_or(|sky| s.1 > sky)) { + let screen = |m: &Option| m.and_then(|m| cam.try_to_screen(m, w, h)).map(|(x, y)| rect.min + vec2(x as f32, y as f32)); + for (i, (l, m)) in app.map.labels.iter().zip(&app.map.label_map).enumerate() { + if let Some(pos) = screen(m).filter(|p| sky.is_none_or(|sky| f64::from(p.y - rect.min.y) > sky)) { let moved = turns.then(|| { let mut l = l.clone(); rustgis_engine::render::turn_label(&mut l, &at); l }); - draw_label(&painter, moved.as_ref().unwrap_or(l), rect.min + vec2(x as f32, y as f32)); + let feature = app.map.callout_map.get(i).and_then(screen); + draw_label(&painter, &callout_from(moved.as_ref().unwrap_or(l), pos, feature), pos); } } } else { let s = if globe_view { 1.0 } else { (old.res / cam.res) as f32 }; for l in &app.map.labels { - draw_label(&painter, l, tex_rect.min + vec2(l.x, l.y) * s); + let at = tex_rect.min + vec2(l.x, l.y) * s; + // A callout's feature moves with the map, its text's end with the text. + let feature = l.collide.callout.filter(|_| s != 1.0).map(|c| tex_rect.min + vec2(l.x + c[0], l.y + c[1]) * s); + draw_label(&painter, &callout_from(l, at, feature), at); } } if app.map.hide_overlays { @@ -1271,7 +1303,10 @@ fn show_gpu_frame(app: &mut RustGisApp, scene: rustgis_engine::render::gpu_scene app.map.label_lonlat.clear(); // Where its labels stand: a turn starting from a north-up frame is draped too (on the CPU, // when the GPU doesn't turn the map), and they stay on their ground through it. - app.map.label_map = app.map.labels.iter().map(|l| cam.try_to_map(l.x as f64, l.y as f64, view.width, view.height)).collect(); + app.map.callout_lonlat.clear(); + let to_map = |x: f32, y: f32| cam.try_to_map(x as f64, y as f64, view.width, view.height); + app.map.label_map = app.map.labels.iter().map(|l| to_map(l.x, l.y)).collect(); + app.map.callout_map = app.map.labels.iter().map(|l| l.collide.callout.and_then(|c| to_map(l.x + c[0], l.y + c[1]))).collect(); app.map.label_camera = cam; app.map.attribution = scene.attribution; app.map.last_render = time; @@ -1311,18 +1346,28 @@ fn show_frame( if !(globe_view && key.draft) { app.map.labels = r.labels; app.map.labels.splice(0..0, r.unplaced); - app.map.label_lonlat = match globe_view { + (app.map.label_lonlat, app.map.callout_lonlat) = match globe_view { true => { let g = Globe::new(&cam, w, h); - app.map.labels.iter().map(|l| g.inverse(l.x as f64, l.y as f64)).collect() + let at = |x: f32, y: f32| g.inverse(x as f64, y as f64); + ( + app.map.labels.iter().map(|l| at(l.x, l.y)).collect(), + app.map.labels.iter().map(|l| l.collide.callout.and_then(|c| at(l.x + c[0], l.y + c[1]))).collect(), + ) } - false => Vec::new(), + false => (Vec::new(), Vec::new()), }; // Where its labels stand while its drape moves (a north-up frame's too: a turn starting // from it is draped). - app.map.label_map = match !globe_view { - true => app.map.labels.iter().map(|l| cam.try_to_map(l.x as f64, l.y as f64, view.width, view.height)).collect(), - false => Vec::new(), + (app.map.label_map, app.map.callout_map) = match !globe_view { + true => { + let to_map = |x: f32, y: f32| cam.try_to_map(x as f64, y as f64, view.width, view.height); + ( + app.map.labels.iter().map(|l| to_map(l.x, l.y)).collect(), + app.map.labels.iter().map(|l| l.collide.callout.and_then(|c| to_map(l.x + c[0], l.y + c[1]))).collect(), + ) + } + false => (Vec::new(), Vec::new()), }; app.map.label_camera = cam; } @@ -1445,7 +1490,94 @@ fn place_galley(anchor: Align2, at: Pos2, g: &egui::Galley) -> (Pos2, Rect) { (r.min - g.rect.min.to_vec2(), r) } +/// A label being moved with the Move Label tool: as it was drawn when grabbed (in the view's +/// points), what it labels, and the map and label class it is of, named then (so it lands in +/// those, whatever is reordered or shown while it moves). +struct MovingLabel { + label: PlacedLabel, + layer: u32, + feature: u64, + class: String, + project: u64, + map: usize, +} + +/// The layer's label drawn at a point of the map view (relative to it as `rect` is), topmost first: +/// what the Move Label tool grabs, as it is drawn now. Labels not of a layer's features aren't; +/// nor any while a moving globe or tilted map draws them from where they were placed. +fn label_at(app: &RustGisApp, rect: Rect, p: Pos2) -> Option { + if app.map.key.is_none_or(|key| key.rev != app.session.revision) { + return None; + } + let cam = app.session.map().camera; + let placed = app.map.label_camera; + // A flat map moving draws the labels placed last with its frame: shifted, and spread apart + // or closed up as it zooms (as `main_view` does). + let (shift, s) = if placed == cam { + (vec2(0.0, 0.0), 1.0) + } else if app.session.map().projection != Projection::Globe && placed.pitch == 0.0 && cam.pitch == 0.0 && placed.bearing == cam.bearing + { + (app.map.tex_rect.min - rect.min, (app.map.tex_camera.res / cam.res) as f32) + } else { + return None; + }; + if !(s.is_finite() && s > 0.0) { + return None; + } + let at = p - rect.min; + app.map.labels.iter().rev().filter(|l| l.collide.source.is_some() && !l.text.is_empty()).find_map(|l| { + let mut l = l.clone(); + // Where it is drawn now: its text at its own size, its callout's feature where it is. + (l.x, l.y) = (shift.x + l.x * s, shift.y + l.y * s); + l.collide.callout_anchor = l.collide.callout_anchor.map(|[ax, ay]| [ax * s, ay * s]); + l.collide.callout = l.collide.callout.map(|[x0, y0, x1, y1]| [x0 * s, y0 * s, x1, y1]); + let e = rustgis_engine::render::label_extent(&l)?; + let r = Rect::from_min_max(pos2(e[0], e[1]), pos2(e[2], e[3])).expand(3.0); + r.contains(pos2(at.x, at.y)).then_some(l) + }) +} + +/// A label drawn at `at` while the map moves, its callout's line starting at its feature where +/// that now is (none: as it was placed). +fn callout_from(l: &PlacedLabel, at: Pos2, feature: Option) -> std::borrow::Cow<'_, PlacedLabel> { + match (l.collide.callout, feature) { + (Some([_, _, x1, y1]), Some(f)) => { + let mut l = l.clone(); + l.collide.callout = Some([f.x - at.x, f.y - at.y, x1, y1]); + std::borrow::Cow::Owned(l) + } + _ => std::borrow::Cow::Borrowed(l), + } +} + +/// A label moved by `d` (points): its text there, its feature where it was, and its callout's line +/// from the feature to the text when its class has callouts. +fn moved_label(l: &PlacedLabel, d: egui::Vec2) -> PlacedLabel { + let mut m = l.clone(); + m.x += d.x; + m.y += d.y; + m.collide.callout_anchor = l.collide.callout_anchor.map(|[ax, ay]| [ax - d.x, ay - d.y]); + m.collide.callout = rustgis_engine::render::callout_to(&m); + m +} + +/// The name of a layer's label class by its place in the labeling order. +fn label_class_name(app: &RustGisApp, layer: u32, class: u32) -> Option { + app.session.map().layer(layer)?.labels.in_order().nth(class as usize).map(|c| c.name.clone()) +} + +/// Paints a moved-label preview, keeping callouts below text and curved glyph halos below ink. fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { + // A callout's line, from its feature to the edge of the text, under the text (its halo + // under the line, so it shows over the map). + if let Some([x0, y0, x1, y1]) = l.collide.callout { + let ends = [at + vec2(x0, y0), at + vec2(x1, y1)]; + let width = rustgis_engine::render::CALLOUT_WIDTH; + if let Some(h) = l.halo { + p.line_segment(ends, Stroke::new(width + 2.0, crate::theme::c32(h))); + } + p.line_segment(ends, Stroke::new(width, crate::theme::c32(l.color))); + } // Curved along a line: each character turned into its place, every character's halo first, // so none covers the letter before it. if !l.glyphs.is_empty() { @@ -2256,8 +2388,17 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect .then(|| text_graphic_at(app, ui.ctx(), rect, origin)) .flatten() .filter(|hit| crate::graphicformat::selected(app) == Some(*hit)); + // With the Move Label tool, a layer's label grabbed by its text moves; elsewhere the map pans. + let label = (tool == Tool::MoveLabel && !middle).then(|| label_at(app, rect, origin)).flatten(); let kind = if let Some((layer, row)) = grabbed { DragKind::Text { layer, row, origin } + } else if let Some(l) = label + && let Some((layer, feature, class)) = l.collide.source + && let Some(class) = label_class_name(app, layer, class) + { + let (project, map) = (app.session.opened, app.session.project.active_map); + app.map.moving_label = Some(MovingLabel { label: l, layer, feature, class, project, map }); + DragKind::Label { origin } } else if middle { DragKind::Pan } else if tool == Tool::EditVertices && crate::vertexedit::drag_start(app, rect, origin) { @@ -2299,6 +2440,43 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect app.map.sketch.push(c); } } + // A label being moved shows where it will land, its callout's line following it; it is kept + // there, as one undo step, on release. + // Esc, another tool, or another map or project shown drops it where it was. + if let Some((_, DragKind::Label { origin })) = app.map.drag { + let same = app.map.moving_label.as_ref().is_some_and(|m| { + m.project == app.session.opened && m.map == app.session.project.active_map && app.session.tool == Tool::MoveLabel + }); + if !same || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.map.cancel_label_drag(); + return; + } + let end = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); + let moved = app.map.moving_label.as_ref().zip(end).map(|(m, end)| moved_label(&m.label, end - origin)); + if resp.drag_stopped() { + app.map.drag = None; + let to = moved.and_then(|l| screen_to_lonlat(app, rect, rect.min + vec2(l.x, l.y))); + if let (Some(c), Some(m)) = (to, app.map.moving_label.take()) + && let Err(e) = app.run("labels.move", json!({"layer": m.layer, "class": m.class, "feature": m.feature, "to": [c.x, c.y]})) + { + app.session.notify(e, true); + } + app.map.moving_label = None; + return; + } + if let Some(l) = moved { + let t = Tokens::get(ui.ctx()); + let painter = + ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.scope_id().with("label_drag"))).with_clip_rect(rect); + draw_label(&painter, &l, rect.min + vec2(l.x, l.y)); + if let Some(e) = rustgis_engine::render::label_extent(&l) { + let r = Rect::from_min_max(rect.min + vec2(e[0], e[1]), rect.min + vec2(e[2], e[3])); + painter.rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Outside); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + ui.ctx().request_repaint(); + } + } // A dragged text shows where it will land; it is placed there, as one undo step, on release. if let Some((_, DragKind::Text { layer, row, origin })) = app.map.drag { let end = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); @@ -2504,6 +2682,17 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect c }; match tool { + // Labels move by dragging them; Shift-clicking a moved one puts it back. + Tool::MoveLabel => { + if shift + && let Some((layer, feature, class)) = + label_at(app, rect, p).filter(|l| l.collide.pinned).and_then(|l| l.collide.source) + && let Some(name) = label_class_name(app, layer, class) + && let Err(error) = app.run("labels.move", json!({"layer": layer, "class": name, "feature": feature, "to": null})) + { + app.session.notify(error, true); + } + } Tool::Explore => { let r = app.session.run("identify", &json!({"lon": c.x, "lat": c.y, "tolerancePx": 5})); app.map.popup = match r { @@ -2973,6 +3162,7 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: } Tool::EditVertices => Some("Click a feature, then drag its vertices · click an edge to add one"), Tool::ZoomIn => Some("Drag a box or click to zoom in"), + Tool::MoveLabel => Some("Drag a label to where it should be · drag the map to pan · Esc to stop"), Tool::Graphic("point") => Some("Click the map to place a point"), Tool::Graphic("text") => Some("Click the map to place text"), Tool::Graphic("line" | "curve") => Some("Click to add vertices · double-click to finish"), @@ -4296,6 +4486,85 @@ mod tests { h.state().session.map().layer_by_name("Points").unwrap().selection.clone() } + /// The Move Label tool: a label dragged and let go is kept where it was put, as one undo + /// step; Esc while dragging, or another tool, drops it where it was; Shift-click puts a moved + /// label back and leaves one never moved (no undo step). + #[test] + fn move_label_drags_cancels_and_puts_back() { + let mut h = selection_harness(); + for (i, point) in [[0.5, 0.5], [3.5, 2.0], [2.5, 0.5], [1.0, 4.0]].into_iter().enumerate() { + let p = json!({"layer": "Labelled", "geometry": {"type": "Point", "coordinates": point}, "attributes": {"name": format!("Place {i}")}}); + if i == 0 { + h.state_mut().run("layer.template", json!({"template": "points", "name": "Labelled"})).unwrap(); + } + h.state_mut().run("edit.addFeature", p).unwrap(); + } + let id = h.state().session.map().layer_by_name("Labelled").unwrap().id; + if let Some(l) = h.state_mut().session.map_mut().layer_mut(id) { + l.labels.enabled = true; + l.labels.default.field = "name".into(); + } + h.state_mut().run("tool.set", json!({"tool": "moveLabel"})).unwrap(); + let end = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !h.state().map.labels.iter().any(|l| l.text == "Place 3") || h.state().map.drawing() { + assert!(std::time::Instant::now() < end, "labels never drawn"); + std::thread::sleep(std::time::Duration::from_millis(2)); + h.step(); + } + let rect = h.state().map.view_rect.unwrap().0; + let grab = |h: &egui_kittest::Harness<'_, RustGisApp>| { + let l = h.state().map.labels.iter().find(|l| l.text == "Place 3").unwrap(); + rect.min + vec2(l.x, l.y) + }; + let moved = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).unwrap().labels.default.moved.clone(); + let none = egui::Modifiers::NONE; + let drag = |h: &mut egui_kittest::Harness<'_, RustGisApp>, escape: bool| { + let at = grab(h); + selection_pointer(h, at, None, none); + selection_pointer(h, at, Some(true), none); + for i in 1..=6 { + selection_pointer(h, at + vec2(0.0, -8.0 * i as f32), None, none); + } + if escape { + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: none }); + h.step(); + } + selection_pointer(h, at + vec2(0.0, -48.0), Some(false), none); + }; + // Esc mid-drag: nothing saved. + drag(&mut h, true); + assert!(moved(&h).is_empty(), "Esc cancels the move"); + assert!(h.state().map.moving_label.is_none()); + // Dragged and let go: kept. + h.state_mut().run("tool.set", json!({"tool": "moveLabel"})).unwrap(); + h.step(); + drag(&mut h, false); + assert_eq!(moved(&h).len(), 1, "the move is kept"); + // Shift-click on the moved one: put back, and moved again by undo. + let pinned = h.state().map.labels.iter().find(|l| l.collide.pinned).map(|l| rect.min + vec2(l.x, l.y)); + let shift = egui::Modifiers { shift: true, ..none }; + let at = pinned.expect("the moved label drawn"); + selection_pointer(&mut h, at, None, shift); + selection_pointer(&mut h, at, Some(true), shift); + selection_pointer(&mut h, at, Some(false), shift); + assert!(moved(&h).is_empty(), "put back"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(moved(&h).len(), 1, "undone"); + let end = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !h.state().map.labels.iter().any(|l| l.collide.pinned) || h.state().map.drawing() { + assert!(std::time::Instant::now() < end, "labels never drawn again"); + std::thread::sleep(std::time::Duration::from_millis(2)); + h.step(); + } + // Shift-click on a label never moved: no undo step, so undo takes back the move. + let other = h.state().map.labels.iter().find(|l| l.text == "Place 1" && !l.collide.pinned).map(|l| rect.min + vec2(l.x, l.y)); + let at = other.expect("another label drawn"); + selection_pointer(&mut h, at, Some(true), shift); + selection_pointer(&mut h, at, Some(false), shift); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(moved(&h).is_empty(), "the Shift-click left no step of its own"); + } + #[test] fn selection_tools_polygon_menu_repeat_and_fixed_default() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a7349204e..e78cdb102 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -4615,6 +4615,24 @@ fn label_groups(app: &mut RustGisApp, ui: &mut Ui) { } } }); + // Labels put by hand: the Move Label tool (on and off; Esc leaves it too), and putting the + // layer's moved labels back. + col(ui, |ui| { + let moving = app.session.tool == Tool::MoveLabel; + let to = if moving { Tool::Explore } else { Tool::MoveLabel }; + let desc = "Drag a label to where it should be; its callout's line follows. Shift-click a moved label to put it back."; + small(ui, app, "label", "Move Label", desc, "tool.set", json!({"tool": to.code()}), moving); + small( + ui, + app, + "undo", + "Reset Moved", + "Put this layer's moved labels back where their classes place them.", + "labels.resetMoved", + json!({"all": true}), + false, + ); + }); }); group(ui, "Label Range", |ui| { ui.vertical(|ui| { diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 900211869..e188f5f5a 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -453,6 +453,24 @@ Turn labels on per layer (Labeling › Labels › **Label**), then: Lock keeps the ones placed. The distance grows with the labels at a reference scale (`labels.placement={"layer": "Rivers", "position": "curved", "repeat": 144}`; `null` or `0` is the one at the middle). +- **Show all labels** (Labeling pane › Position): labels among the view's 20,000 highest-priority + candidates are drawn, over the others where + they crowd, rather than some left out (`labels.placement={"layer": "Cities", "showAll": true}`). +- **Callouts** (Labeling pane › Position): a label crowded off its spot moves to the nearest free + spot nearby, and a line is drawn from its feature to the text, as QGIS's callouts. **at most** + sets how long the line may be (5 to 500 points, 40 to start with): a label with no free spot + that close is left out, or drawn over the others with Show all labels. The line takes the + label's colour, over its halo. Labels along lines aren't moved + (`labels.placement={"layer": "Cities", "callout": 60}`; `true` keeps the length, `false` turns + them off). +- **Move Label** (Labeling tab › Label Placement): drag a label to where it should be, as with + QGIS's Move Label tool. It stays there as the map moves (kept by its feature, in longitude and + latitude, and saved with the project), is drawn whatever is under it, and the other labels keep + off it; with Callouts on, its line follows it back to its feature, however far it went. + Shift-click a moved label to put it back, or **Reset Moved** for all of the layer's. Dragging + anything else pans the map; Esc leaves the tool + (`labels.move={"layer": "Cities", "feature": 12, "to": [2.5, 49.1]}`; `"to": null` puts it back; + `labels.resetMoved={"layer": "Cities", "all": true}`). - **Font**: the labels' font family, any installed on the computer (Labeling pane › Symbol › **Font**; type part of a name to find it), or the label font the interface uses. Bold and italic take the family's own bold and italic faces where it has them. The family is saved by @@ -487,6 +505,9 @@ Turn labels on per layer (Labeling › Labels › **Label**), then: take the place a less important one had. The class's offset applies to every place, and Lock keeps the place each label took (`labels.placement={"layer": "Cities", "otherPositions": true}`). +Repair Data Source resets saved label moves, because replacement data can reuse feature ids. +A notice gives the count; Undo restores the old source and moves. + - **Lock** keeps the labelled features fixed while you pan and zoom; **Pause** stops drawing labels for faster navigation; **View Unplaced** draws the labels that didn't fit in red. They apply to the whole map, so they are on the ribbon (Labeling › Map Labeling), not in the pane.