Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 94 additions & 5 deletions crates/engine/src/cmd/labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,88 @@

use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr};
use rustgis_map::symbology::{MAX_LABEL_CLASSES, label_class_name};
use rustgis_map::{LabelClass, LabelMatcher, LabelPlacement, Labeling, Renderer, Rgba, Symbol};
use rustgis_map::{LabelBackground, LabelClass, LabelMatcher, LabelPlacement, Labeling, Renderer, Rgba, Symbol};
use serde_json::{Value, json};

use super::layer::s_req;
use super::{b, f, need_feature_layer, ps};
use crate::{CmdResult, CommandSpec as C, Session};

/// A number, or a number written as text (as `f` reads a parameter).
fn number(v: &Value) -> Option<f64> {
v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x: &f64| x.is_finite())
}

/// A label offset given as `[right, down]` (points), each kept within the range a class keeps.
fn label_offset(v: &Value) -> Option<[f32; 2]> {
let range = rustgis_map::symbology::LABEL_OFFSET;
let [x, y] = v.as_array()?.as_slice() else { return None };
let within = |v: &Value| number(v).map(|v| v.clamp(*range.start(), *range.end()) as f32);
Some([within(x)?, within(y)?])
}

/// What `labels.style`'s `background` does to a class's background.
pub(super) type BackgroundChange = Box<dyn Fn(Option<LabelBackground>) -> Option<LabelBackground>>;

/// Reads `labels.style`'s `background`: false or null takes it away, true puts one on (the default,
/// or the one it has), and an object sets the parts it gives on the one it has (else on the
/// default). Anything else, or a part that isn't what it should be, is an error.
pub(super) fn background_change(v: &Value) -> Result<BackgroundChange, String> {
let o = match v {
Value::Null | Value::Bool(false) => return Ok(Box::new(|_| None)),
Value::Bool(true) => return Ok(Box::new(|b| Some(b.unwrap_or_default()))),
Value::Object(o) => o,
_ => return Err("background must be true, false, null or {fill, stroke, strokeWidth, padding, radius}".into()),
};
// A colour, or "" or null for none.
let color = |k: &str| -> Result<Option<Option<Rgba>>, String> {
match o.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(c)) if c.trim().is_empty() => Ok(Some(None)),
Some(Value::String(c)) => Rgba::from_hex(c.trim())
.map(|c| Some(Some(c)))
.ok_or_else(|| format!("background.{k} must be #RRGGBB or #RRGGBBAA, or \"\"")),
Some(_) => Err(format!("background.{k} must be #RRGGBB or #RRGGBBAA, or \"\"")),
}
};
let number = |v: &Value| number(v).map(|v| v as f32);
let points = |k: &str| -> Result<Option<f32>, String> {
o.get(k).map(|v| number(v).ok_or_else(|| format!("background.{k} must be a number of points"))).transpose()
};
let (fill, stroke) = (color("fill")?, color("stroke")?);
let (stroke_width, radius) = (points("strokeWidth")?, points("radius")?);
let padding = match o.get("padding") {
None => None,
Some(v) => match v.as_array().map(Vec::as_slice) {
Some([x, y]) => Some([number(x), number(y)]),
_ => None,
}
.and_then(|[x, y]| Some([x?, y?]))
.map(Some)
.ok_or("background.padding must be [across, up and down] in points")?,
};
Ok(Box::new(move |b| {
let mut b = b.unwrap_or_default();
if let Some(c) = fill {
b.fill = c;
}
if let Some(c) = stroke {
b.stroke = c;
}
if let Some(w) = stroke_width {
b.stroke_width = w;
}
if let Some(p) = padding {
b.padding = p;
}
if let Some(r) = radius {
b.radius = r;
}
Some(b.checked())
}))
}

pub fn commands() -> Vec<C> {
vec![
C::new("labels.lock", "Lock", "Labeling › Map Labeling", |s, p| {
Expand Down Expand Up @@ -55,13 +130,27 @@ pub fn commands() -> Vec<C> {
.when(need_feature_layer)
.params(r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "closest"?: metres|null, "farthest"?: metres|null, "clear"?: bool}"#),
C::new("labels.placement", "Label Placement", "Labeling › Label Placement", |s, p| {
let code = ps(p, "position").ok_or("position is required")?;
let pos = LabelPlacement::from_code(code).ok_or_else(|| format!("unknown placement '{code}'"))?;
with_class(s, p, |c| c.placement = pos)
let pos = match ps(p, "position") {
Some(code) => Some(LabelPlacement::from_code(code).ok_or_else(|| format!("unknown placement '{code}'"))?),
None if p.get("offset").is_some() => None,
None => return Err("position is required (or an offset)".into()),
};
let offset = match p.get("offset") {
None => None,
Some(o) => Some(label_offset(o).ok_or("offset must be [right, down] in points")?),
};
with_class(s, p, |c| {
if let Some(pos) = pos {
c.placement = pos;
}
if let Some(o) = offset {
c.offset = o;
}
})
})
.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)}"#,
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), "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)}"#,
),
// Label classes: each labels every feature its clause takes, as in ArcGIS Pro; the classes
// listed first, Default last, are placed first (`Labeling`, #732).
Expand Down
9 changes: 8 additions & 1 deletion crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,14 @@ pub fn commands() -> Vec<C> {
None => None,
Some(_) => Some(f(p, "spacing").ok_or("spacing must be a number of points")?.clamp(sp.0, sp.1) as f32),
};
let background = match p.get("background") {
None => None,
Some(v) => Some(super::labels::background_change(v)?),
};
super::labels::with_class(s, p, |c| {
if let Some(change) = &background {
c.background = change(c.background.clone());
}
if let Some(v) = halo_color {
c.halo_color = v;
}
Expand Down Expand Up @@ -386,7 +393,7 @@ pub fn commands() -> Vec<C> {
})
})
.when(need_feature_layer)
.params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "haloColor"?: "#RRGGBB"|"#RRGGBBAA"|"" (""/null: white or dark, whichever stands out from the text), "haloWidth"?: points 0-10|null (null: the thin halo), "spacing"?: points between letters (-5 to 50), "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font)}"##),
.params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "haloColor"?: "#RRGGBB"|"#RRGGBBAA"|"" (""/null: white or dark, whichever stands out from the text), "haloWidth"?: points 0-10|null (null: the thin halo), "spacing"?: points between letters (-5 to 50), "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font), "background"?: false|null (none) | true (white, a thin dark outline) | {"fill"?: "#RRGGBB"|"#RRGGBBAA"|"" ("": none), "stroke"?: colour|"" (the outline; "": none), "strokeWidth"?: points 0-10, "padding"?: [across, up and down] points 0-50, "radius"?: corner radius in points 0-50} (a rectangle behind the text; what isn't given is kept, or the default's)}"##),
C::new("layer.definitionQuery", "Definition Query", "Data › Definition Query", |s, p| {
let q = ps(p, "where").map(str::trim).unwrap_or("").to_string();
let id = s.layer_id(p)?;
Expand Down
5 changes: 5 additions & 0 deletions crates/engine/src/cmd/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,11 @@ pub fn web_style(l: &Layer) -> Value {
if c.spacing != 0.0 && c.size > 0.0 {
layout["text-letter-spacing"] = json!(f64::from(c.spacing) / f64::from(c.size));
}
// Its offset in ems too, unless along a line, which the map doesn't move (a background
// has no MapLibre equivalent: left out).
if c.offset != [0.0; 2] && c.size > 0.0 && !(c.placement.along_line() && gt == GeometryType::Polyline) {
layout["text-offset"] = json!(c.offset.map(|o| f64::from(o) / f64::from(c.size)));
}
layers.push(json!({"id": format!("{src}-labels"), "type": "symbol", "source": src, "layout": layout,
"paint": {"text-color": c.color.hex(), "text-halo-color": halo_color, "text-halo-width": halo_width}}));
}
Expand Down
85 changes: 85 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2183,6 +2183,81 @@ fn labels_take_a_halo_colour_and_width_and_letter_spacing() {
}
}

/// A label class takes an offset with its placement and a background with its text symbol; junk
/// is refused, numbers kept within their ranges, and each change undoes.
#[test]
fn labels_take_an_offset_and_a_background() {
use rustgis_map::{LabelBackground, Rgba};
let mut s = world();
let class = |s: &Session| {
let c = &s.map().layer_by_name("Cities").unwrap().labels.default;
(c.placement, c.offset, c.background.clone())
};
let (placement, offset, background) = class(&s);
assert_eq!((offset, background), ([0.0; 2], None));
// An offset alone keeps the placement; both together set both.
let mark = s.notice_mark();
s.run("labels.placement", &json!({"layer": "Cities", "offset": [3, "-2.5"]})).unwrap();
assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read");
assert_eq!((class(&s).0, class(&s).1), (placement, [3.0, -2.5]));
s.run("labels.placement", &json!({"layer": "Cities", "position": "right", "offset": [1e9, -1e9]})).unwrap();
assert_eq!((class(&s).0, class(&s).1), (rustgis_map::LabelPlacement::Right, [200.0, -200.0]));
s.run("labels.placement", &json!({"layer": "Cities", "position": "below"})).unwrap();
assert_eq!(class(&s).1, [200.0, -200.0], "a position alone keeps the offset");
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(class(&s).0, rustgis_map::LabelPlacement::Right, "undoes");
for bad in [json!({}), json!({"offset": "far"}), json!({"offset": [1]}), json!({"offset": [1, "x"]}), json!({"offset": null})] {
let mut p = bad.clone();
p["layer"] = json!("Cities");
assert!(s.run("labels.placement", &p).is_err(), "{bad}");
}
// A background: on (the default's), parts set on it, off.
s.run("labels.style", &json!({"layer": "Cities", "background": true})).unwrap();
assert_eq!(class(&s).2, Some(LabelBackground::default()));
let mark = s.notice_mark();
let parts = json!({"fill": "#FFEE00", "stroke": "", "strokeWidth": 99, "padding": [2, "1"], "radius": -1});
s.run("labels.style", &json!({"layer": "Cities", "background": parts})).unwrap();
assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read");
let want = LabelBackground { fill: Some(Rgba::rgb(0xFF, 0xEE, 0)), stroke: None, stroke_width: 10.0, padding: [2.0, 1.0], radius: 0.0 };
assert_eq!(class(&s).2, Some(want.clone()));
s.run("labels.style", &json!({"layer": "Cities", "background": {"radius": 1e300}})).unwrap();
assert_eq!(class(&s).2.map(|b| b.radius), Some(50.0), "past f32's range: the most");
s.run("labels.style", &json!({"layer": "Cities", "background": {"radius": 3}})).unwrap();
assert_eq!(class(&s).2, Some(LabelBackground { radius: 3.0, ..want.clone() }), "what isn't given is kept");
s.run("labels.style", &json!({"layer": "Cities", "background": true})).unwrap();
assert_eq!(class(&s).2.map(|b| b.radius), Some(3.0), "on again keeps it");
s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap();
assert!(class(&s).2.is_some(), "other settings leave it");
s.run("labels.style", &json!({"layer": "Cities", "background": false})).unwrap();
assert_eq!(class(&s).2, None);
s.run("edit.undo", &json!({})).unwrap();
assert!(class(&s).2.is_some(), "undoes");
s.run("labels.style", &json!({"layer": "Cities", "background": null})).unwrap();
assert_eq!(class(&s).2, None);
// Set on none, the parts go on the default's.
s.run("labels.style", &json!({"layer": "Cities", "background": {"fill": ""}})).unwrap();
assert_eq!(class(&s).2, Some(LabelBackground { fill: None, ..LabelBackground::default() }));
for bad in [
json!({"background": "box"}),
json!({"background": 1}),
json!({"background": {"fill": "pink"}}),
json!({"background": {"stroke": 7}}),
json!({"background": {"strokeWidth": "wide"}}),
json!({"background": {"padding": 2}}),
json!({"background": {"padding": [2]}}),
json!({"background": {"radius": null}}),
] {
let mut p = bad.clone();
p["layer"] = json!("Cities");
assert!(s.run("labels.style", &p).is_err(), "{bad}");
}
// The project keeps them.
s.run("labels.placement", &json!({"layer": "Cities", "offset": [1, 2]})).unwrap();
let text = serde_json::to_string(&s.project).unwrap();
let reopened = Session::new(serde_json::from_str(&text).unwrap());
assert_eq!(class(&reopened), class(&s));
}

/// A lock names each label by its class's place in the list: a change to the list (a class added,
/// moved or removed) locks the labels again on the next frame, a change within a class keeps the
/// lock (#732).
Expand Down Expand Up @@ -7303,6 +7378,16 @@ fn web_style_carries_the_layers_scale_limits_as_zooms() {
assert_eq!(halo(&s), "rgba(20,22,26,0.824)");
s.map_mut().layer_mut(id).unwrap().labels.default.halo_color = Some(rustgis_map::Rgba::new(255, 0, 0, 128));
assert_eq!(halo(&s), "rgba(255,0,0,0.502)");
// An offset in ems, as MapLibre has it; none without one.
let offset = |s: &Session| {
let style = crate::cmd::sharing::web_style(s.map().layer(id).unwrap());
let label = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "symbol").cloned().unwrap();
label["layout"].get("text-offset").cloned()
};
assert_eq!(offset(&s), None);
let c = &mut s.map_mut().layer_mut(id).unwrap().labels.default;
(c.size, c.offset) = (10.0, [5.0, -2.5]);
assert_eq!(offset(&s), Some(json!([0.5, -0.25])));
s.map_mut().layer_mut(id).unwrap().labels.default.visible = false;
assert!(!labelled(&s));
}
Expand Down
4 changes: 2 additions & 2 deletions crates/map/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ pub use color::{Ramp, RampGroup, Rgba};
pub use globe::{Globe, Projection};
pub use raster::{RasterDefaults, RasterLayer, RasterRenderer, RasterStyle, Resampling, Stretch};
pub use symbology::{
ClassMethod, FontName, Glyph, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers,
LineStroke, Marker, MarkerRotation, Placements, Renderer, Symbol,
ClassMethod, FontName, Glyph, LabelBackground, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin,
LineMark, LineMarkers, LineStroke, Marker, MarkerRotation, Placements, Renderer, Symbol,
};
pub use tiles::{TileKind, TileLayer};

Expand Down
Loading
Loading