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
23 changes: 14 additions & 9 deletions crates/engine/src/cmd/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ pub fn commands() -> Vec<C> {
r#"{"layout"?, "shape"?: "rectangle"|"circle"|"ellipse"|"polygon"|"lasso", "points"?: [[u, v], …] (polygon/lasso, 0..1 across the frame), "map"?: name, "x"?, "y"?, "w"?, "h"?: mm}"#,
),
C::new("layout.addElement", "Add Element", "Publish › Map Surrounds", add_element).params(
r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA", "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out <all other values>), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool, "style"?: "lines"|"ticks"|"linesAndTicks" (grid) or "classic"|"compass"|"triangle"|"star" (northArrow) or "alternating"|"stepped"|"line" (scaleBar), "tickLength"?: mm, "format"?: "decimal"|"dms", "hemisphere"?: bool (N/S/E/W rather than a sign), "edges"?: ["top","bottom","left","right"] (labelled edges), "labelOrientation"?: "horizontal"|"vertical", "off"?: bool (grid; options not given keep the grid's), "outline"?: "#RRGGBB" (northArrow), "units"?: "auto"|"kilometers"|"meters"|"miles"|"feet", "divisions"?: 1..10 (scaleBar), "x"?, "y"?, "w"?, "h"?: mm}"##,
r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA", "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out <all other values>), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow; scaleBar: the alternating bar's light divisions, default white), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool, "style"?: "lines"|"ticks"|"linesAndTicks" (grid) or "classic"|"compass"|"triangle"|"star" (northArrow) or "alternating"|"stepped"|"line" (scaleBar), "tickLength"?: mm, "format"?: "decimal"|"dms", "hemisphere"?: bool (N/S/E/W rather than a sign), "edges"?: ["top","bottom","left","right"] (labelled edges), "labelOrientation"?: "horizontal"|"vertical", "off"?: bool (grid; options not given keep the grid's), "outline"?: "#RRGGBB" (northArrow), "units"?: "auto"|"kilometers"|"meters"|"miles"|"feet", "divisions"?: 1..10 (scaleBar, whose "color" is its lines, dark divisions and labels, default #222222), "x"?, "y"?, "w"?, "h"?: mm}"##,
),
C::new("layout.update", "Element Properties", "Publish › Element", update).params(
r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"? (scaleBar), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##,
r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line; scaleBar: its lines, dark divisions and labels), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given; a scaleBar's fill is the alternating bar's light divisions), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"? (scaleBar), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##,
),
C::new("layout.guides", "Guides", "Publish › Page Setup", guides).params(
r#"{"layout"?, "x"?: [mm] (vertical guides, from the page's left edge), "y"?: [mm] (horizontal guides, from its top edge), "clear"?: bool (remove every guide first)}"#,
Expand Down Expand Up @@ -2207,9 +2207,12 @@ fn corner_radius(p: &Value, d: f64) -> Result<f64, String> {
}
}

/// The colour `p` gives as `k`, or `d` when it gives none (absent or null). Any other value is
/// refused rather than ignored.
pub(crate) fn color(p: &Value, k: &str, d: Rgba) -> Result<Rgba, String> {
match ps(p, k) {
Some(c) => Rgba::from_hex(c).ok_or_else(|| format!("{k} must be #RRGGBB or #RRGGBBAA")),
let bad = || format!("{k} must be #RRGGBB or #RRGGBBAA");
match p.get(k).filter(|v| !v.is_null()) {
Some(v) => v.as_str().and_then(Rgba::from_hex).ok_or_else(bad),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
None => Ok(d),
}
}
Expand Down Expand Up @@ -2779,8 +2782,8 @@ fn restyle(kind: &ElementKind, p: &Value) -> Result<Option<ElementKind>, String>
outline: color(p, "outline", *outline)?,
}))
}
ElementKind::ScaleBar { frame, units, divisions, style } => {
if ["style", "units", "divisions"].iter().all(|k| p.get(k).is_none()) {
ElementKind::ScaleBar { frame, units, divisions, style, color: c, fill } => {
if ["style", "units", "divisions", "color", "fill"].iter().all(|k| p.get(k).is_none()) {
return Ok(None);
}
let style = match ps(p, "style") {
Expand All @@ -2799,7 +2802,8 @@ fn restyle(kind: &ElementKind, p: &Value) -> Result<Option<ElementKind>, String>
Some(_) => f(p, "divisions").ok_or("divisions is a number")?.round().clamp(1.0, MAX_SCALE_DIVISIONS as f64) as usize,
None => *divisions,
};
Ok(Some(ElementKind::ScaleBar { frame: *frame, units, divisions, style }))
let (color, fill) = (color(p, "color", *c)?, color(p, "fill", *fill)?);
Comment thread
jgregoriorincon marked this conversation as resolved.
Resolved
Ok(Some(ElementKind::ScaleBar { frame: *frame, units, divisions, style, color, fill }))
}
ElementKind::Rectangle { fill, outline, width, .. } | ElementKind::Ellipse { fill, outline, width } => {
if ["fill", "outline", "color", "width", "radius"].iter().all(|k| p.get(k).is_none()) {
Expand Down Expand Up @@ -2981,8 +2985,9 @@ fn update(s: &mut Session, p: &Value) -> CmdResult {
),
None => None,
};
let col = match ps(p, "color") {
Some(c) => Some(Rgba::from_hex(c).ok_or("color must be #RRGGBB or #RRGGBBAA")?),
// A colour that isn't text is refused, as `color` does when adding (the default is unused).
let col = match p.get("color").filter(|v| !v.is_null()) {
Comment thread
jgregoriorincon marked this conversation as resolved.
Resolved
Some(_) => Some(color(p, "color", Rgba::rgb(0, 0, 0))?),
None => None,
};
// A text element's italic, alignment and spacing, checked before anything changes.
Expand Down
49 changes: 44 additions & 5 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5296,17 +5296,56 @@ fn layout_surrounds_take_cartographic_options() {
));
s.run("layout.update", &json!({"element": bar, "divisions": "2"})).unwrap();
assert!(matches!(find(&s, "scaleBar").kind, ElementKind::ScaleBar { units: ScaleUnits::Miles, divisions: 2, .. }));
for bad in [json!({"units": "parsecs"}), json!({"divisions": "many"}), json!({"style": "zigzag"})] {
// Its colours: the lines and labels, and the alternating bar's light divisions; one given
// leaves the other as it was.
assert!(matches!(
find(&s, "scaleBar").kind,
ElementKind::ScaleBar { color, fill, .. } if color == Rgba::rgb(0x22, 0x22, 0x22) && fill == Rgba::rgb(0xFF, 0xFF, 0xFF)
));
s.run("layout.update", &json!({"element": bar, "color": "#1F4E79"})).unwrap();
s.run("layout.update", &json!({"element": bar, "fill": "#00000080"})).unwrap();
assert!(matches!(find(&s, "scaleBar").kind, ElementKind::ScaleBar { fill, .. } if fill == Rgba::new(0, 0, 0, 0x80)));
s.run("layout.update", &json!({"element": bar, "fill": "#FDE68A"})).unwrap();
assert!(matches!(
find(&s, "scaleBar").kind,
ElementKind::ScaleBar { color, fill, divisions: 2, .. }
if color == Rgba::rgb(0x1F, 0x4E, 0x79) && fill == Rgba::rgb(0xFD, 0xE6, 0x8A)
));
let bad = [
json!({"units": "parsecs"}),
json!({"divisions": "many"}),
json!({"style": "zigzag"}),
json!({"color": "red"}),
json!({"fill": "#12"}),
json!({"fill": 5}),
json!({"color": [255, 0, 0]}),
];
for bad in bad {
let mut p = bad.clone();
p["element"] = json!(bar);
assert!(s.run("layout.update", &p).is_err(), "{bad}");
}
let added = s.run("layout.addElement", &json!({"kind": "scaleBar", "units": "feet", "style": "line"})).unwrap()["element"]
.as_u64()
.unwrap() as u32;
// A text's colour is refused as anything but text, when updated as when added; null keeps it.
let text = s.run("layout.addElement", &json!({"kind": "text", "text": "T", "color": "#1F4E79"})).unwrap();
let text = text["element"].as_u64().unwrap() as u32;
let text_color = |s: &Session| match &s.project.layouts[0].element(text).unwrap().kind {
ElementKind::Text { color, .. } => *color,
_ => panic!("a text"),
};
for bad in [json!(5), json!([255, 0, 0]), json!(true)] {
assert!(s.run("layout.addElement", &json!({"kind": "text", "text": "U", "color": bad})).is_err(), "{bad}");
assert!(s.run("layout.update", &json!({"element": text, "color": bad})).is_err(), "{bad}");
}
s.run("layout.update", &json!({"element": text, "color": null, "text": "V"})).unwrap();
assert_eq!(text_color(&s), Rgba::rgb(0x1F, 0x4E, 0x79));
s.run("layout.update", &json!({"element": text, "color": "#B91C1C"})).unwrap();
assert_eq!(text_color(&s), Rgba::rgb(0xB9, 0x1C, 0x1C));
let added = s.run("layout.addElement", &json!({"kind": "scaleBar", "units": "feet", "style": "line", "color": "#B91C1C"})).unwrap();
let added = added["element"].as_u64().unwrap() as u32;
assert!(matches!(
s.project.layouts[0].element(added).unwrap().kind,
ElementKind::ScaleBar { units: ScaleUnits::Feet, divisions: 4, style: ScaleBarStyle::Line, .. }
ElementKind::ScaleBar { units: ScaleUnits::Feet, divisions: 4, style: ScaleBarStyle::Line, color, fill, .. }
if color == Rgba::rgb(0xB9, 0x1C, 0x1C) && fill == Rgba::rgb(0xFF, 0xFF, 0xFF)
));
// The page draws with them all, and they survive a save.
let out = std::env::temp_dir().join(format!("rustgis-surrounds-{}.png", std::process::id()));
Expand Down
20 changes: 19 additions & 1 deletion crates/map/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,10 @@ fn ink() -> Rgba {
Rgba::rgb(0x22, 0x22, 0x22)
}

fn white() -> Rgba {
Rgba::rgb(0xFF, 0xFF, 0xFF)
}

fn yes() -> bool {
true
}
Expand Down Expand Up @@ -885,6 +889,12 @@ pub enum ElementKind {
divisions: usize,
#[serde(default)]
style: ScaleBarStyle,
/// The lines, the alternating bar's dark divisions and the labels.
#[serde(default = "ink")]
color: Rgba,
/// The alternating bar's light divisions.
#[serde(default = "white")]
fill: Rgba,
},
Legend {
frame: u32,
Expand Down Expand Up @@ -1023,7 +1033,14 @@ impl ElementKind {

/// A scale bar for `frame`, in the default style.
pub fn scale_bar(frame: u32) -> ElementKind {
ElementKind::ScaleBar { frame, units: ScaleUnits::default(), divisions: divisions(), style: ScaleBarStyle::default() }
ElementKind::ScaleBar {
frame,
units: ScaleUnits::default(),
divisions: divisions(),
style: ScaleBarStyle::default(),
color: ink(),
fill: white(),
}
}

pub fn code(&self) -> &'static str {
Expand Down Expand Up @@ -1798,6 +1815,7 @@ mod tests {
let bar: ElementKind = serde_json::from_str(r#"{"ScaleBar":{"frame":3}}"#).unwrap();
assert_eq!(bar, ElementKind::scale_bar(3));
assert!(matches!(bar, ElementKind::ScaleBar { units: ScaleUnits::Auto, divisions: 4, style: ScaleBarStyle::Alternating, .. }));
assert!(matches!(bar, ElementKind::ScaleBar { color, fill, .. } if color == ink() && fill == white()));
let grid: Grid = serde_json::from_str(r#"{"step":10,"color":[64,72,85,150],"labels":true}"#).unwrap();
assert_eq!(grid, Grid { step: 10.0, ..Grid::default() });
assert_eq!(grid.edges, GridEdges { top: false, bottom: true, left: true, right: false });
Expand Down
Loading
Loading