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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap

Ribbon feature coverage: **56 of 300 features (18%)** have a command
Ribbon feature coverage: **94 of 316 features (29%)** have a command
today ([docs/parity.md](docs/parity.md)). Counting depth (no layouts, 3D, raster or enterprise
sharing yet), real parity is lower.

Expand Down
50 changes: 25 additions & 25 deletions crates/engine/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ const fn f(tab: &'static str, group: &'static str, name: &'static str, command:
}

pub static FEATURES: &[Feature] = &[
f("Map", "Clipboard", "Paste", None),
f("Map", "Clipboard", "Paste Special", None),
f("Map", "Clipboard", "Cut", None),
f("Map", "Clipboard", "Copy", None),
f("Map", "Clipboard", "Copy Path", None),
f("Map", "Clipboard", "Paste", Some("edit.paste")),
f("Map", "Clipboard", "Paste Special", Some("edit.pasteSpecial")),
f("Map", "Clipboard", "Cut", Some("edit.cut")),
f("Map", "Clipboard", "Copy", Some("edit.copy")),
f("Map", "Clipboard", "Copy Path", Some("layer.copyPath")),
f("Map", "Navigate", "Explore", Some("tool.set")),
f("Map", "Navigate", "Topmost Layer", None),
f("Map", "Navigate", "Visible Layers", None),
f("Map", "Navigate", "Selectable Layers", None),
f("Map", "Navigate", "Selected in Contents", None),
f("Map", "Navigate", "No Pop-ups", None),
f("Map", "Navigate", "Topmost Layer", Some("map.popupMode")),
f("Map", "Navigate", "Visible Layers", Some("map.popupMode")),
f("Map", "Navigate", "Selectable Layers", Some("map.popupMode")),
f("Map", "Navigate", "Selected in Contents", Some("map.popupMode")),
f("Map", "Navigate", "No Pop-ups", Some("map.popupMode")),
f("Map", "Navigate", "Planar Navigation", None),
f("Map", "Navigate", "First-Person Navigation", None),
f("Map", "Navigate", "Bookmarks", Some("bookmark.go")),
Expand All @@ -50,10 +50,10 @@ pub static FEATURES: &[Feature] = &[
f("Map", "Inquiry", "Locate", Some("map.goToXY")),
f("Map", "Inquiry", "Infographics", None),
f("Map", "Inquiry", "Identify", Some("identify")),
f("Map", "Labeling", "Convert", None),
f("Map", "Labeling", "Lock", None),
f("Map", "Labeling", "Pause", None),
f("Map", "Labeling", "View Unplaced", None),
f("Map", "Labeling", "Convert", Some("labels.convert")),
f("Map", "Labeling", "Lock", Some("labels.lock")),
f("Map", "Labeling", "Pause", Some("labels.pause")),
f("Map", "Labeling", "View Unplaced", Some("labels.viewUnplaced")),
f("Map", "Offline", "Download Map", None),
f("Map", "Offline", "Sync", None),
f("Map", "Offline", "Remove", None),
Expand Down Expand Up @@ -193,11 +193,11 @@ pub static FEATURES: &[Feature] = &[
f("View", "Navigation", "Navigator", None),
f("View", "Navigation", "Camera", None),
f("View", "Navigation", "Magnifier", None),
f("Edit", "Clipboard", "Paste", None),
f("Edit", "Clipboard", "Paste Special", None),
f("Edit", "Clipboard", "Cut", None),
f("Edit", "Clipboard", "Copy", None),
f("Edit", "Clipboard", "Copy Path", None),
f("Edit", "Clipboard", "Paste", Some("edit.paste")),
f("Edit", "Clipboard", "Paste Special", Some("edit.pasteSpecial")),
f("Edit", "Clipboard", "Cut", Some("edit.cut")),
f("Edit", "Clipboard", "Copy", Some("edit.copy")),
f("Edit", "Clipboard", "Copy Path", Some("layer.copyPath")),
f("Edit", "Manage Edits", "Save", Some("project.save")),
f("Edit", "Manage Edits", "Discard", Some("edit.undo")),
f("Edit", "Manage Edits", "Topology Options", None),
Expand Down Expand Up @@ -313,13 +313,13 @@ pub static FEATURES: &[Feature] = &[
f("Raster Layer", "Enhancement", "Gamma", Some("raster.enhance")),
f("Raster Layer", "Rotation", "Rotation Type", None),
f("Labeling", "Layer", "Label", Some("labels.enable")),
f("Labeling", "Visibility Range", "Maximum Scale", None),
f("Labeling", "Visibility Range", "Minimum Scale", None),
f("Labeling", "Visibility Range", "Clear Limits", None),
f("Labeling", "Visibility Range", "Closest Distance", None),
f("Labeling", "Visibility Range", "Farthest Distance", None),
f("Labeling", "Visibility Range", "Maximum Scale", Some("labels.scaleRange")),
f("Labeling", "Visibility Range", "Minimum Scale", Some("labels.scaleRange")),
f("Labeling", "Visibility Range", "Clear Limits", Some("labels.scaleRange")),
f("Labeling", "Visibility Range", "Closest Distance", Some("labels.distanceRange")),
f("Labeling", "Visibility Range", "Farthest Distance", Some("labels.distanceRange")),
f("Labeling", "Text Symbol", "Text Symbol Style", Some("labels.style")),
f("Labeling", "Label Placement", "Label Placement Style", None),
f("Labeling", "Label Placement", "Label Placement Style", Some("labels.placement")),
f("Data", "Definition Query", "Layer Definition Query", Some("layer.definitionQuery")),
f("Data", "Selection", "All", Some("select.all")),
f("Data", "Selection", "Switch", Some("select.switch")),
Expand Down
157 changes: 157 additions & 0 deletions crates/engine/src/cmd/clipboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! Map and Edit › Clipboard: copy, cut and paste features, and copy a layer's data path.
//!
//! Copied features are held by the session (with their schema) and also returned as GeoJSON, which
//! the app puts on the system clipboard; paste takes either.

use rustgis_data::{Dataset, Feature, FieldType, GeometryType, Value as Attr};
use serde_json::{Value, json};

use super::{need_layer, need_selection, ps};
use crate::{CmdResult, CommandSpec as C, Session};

/// Largest GeoJSON text accepted by paste (64 MiB).
const MAX_TEXT: usize = 64 << 20;

/// Features on the clipboard, with the schema of the layer they came from.
#[derive(Clone, Debug)]
pub struct Clipboard {
pub data: Dataset,
}

pub fn commands() -> Vec<C> {
vec![
C::new("edit.copy", "Copy", "Edit › Clipboard", |s, _| copy(s)).view().key("Mod+C").when(need_selection),
C::new("edit.cut", "Cut", "Edit › Clipboard", cut).key("Mod+X").when(need_selection),
C::new("edit.paste", "Paste", "Edit › Clipboard", paste)
.key("Mod+V")
.when(need_layer)
.params(r#"{"layer"?, "text"?: GeoJSON text (the copied features when omitted)}"#),
C::new("edit.pasteSpecial", "Paste Special", "Edit › Clipboard", paste_special)
.params(r#"{"name"?: string, "text"?: GeoJSON text (the copied features when omitted)}"#),
C::new("layer.copyPath", "Copy Path", "Edit › Clipboard", copy_path).view().when(need_layer).params(r#"{"layer"?}"#),
]
}

/// The layer whose selection is copied: the active layer if it has one, else the first layer
/// with selected features.
fn source(s: &Session) -> Option<u32> {
let m = s.map();
s.active_layer
.and_then(|id| m.layer(id))
.filter(|l| !l.selection.is_empty())
.or_else(|| m.layers.iter().find(|l| !l.selection.is_empty()))
.map(|l| l.id)
}

fn copy(s: &mut Session) -> CmdResult {
let id = source(s).ok_or("nothing is selected")?;
let l = s.map().layer(id).ok_or("no such layer")?;
let mut data = Dataset::new(l.name.clone(), l.geometry_type(), l.dataset.fields.clone());
data.features = l.selection.iter().filter_map(|r| l.dataset.features.get(*r).cloned()).collect();
let n = data.features.len();
let text = rustgis_data::io::to_geojson(&data).to_string();
let layer = l.name.clone();
s.clipboard = Some(Clipboard { data });
s.notify(format!("Copied {n} features from {layer}"), false);
Ok(json!({"copied": n, "layer": layer, "text": text}))
}

fn cut(s: &mut Session, _: &Value) -> CmdResult {
let id = source(s).ok_or("nothing is selected")?;
let out = copy(s)?;
let l = s.map_mut().layer_mut(id).ok_or("no such layer")?;
let mut sel = std::mem::take(&mut l.selection);
sel.sort_unstable();
sel.dedup();
for r in sel.into_iter().rev() {
if r < l.dataset.features.len() {
l.dataset.features.remove(r);
}
}
l.touch_geometry();
Ok(out)
}

/// The features to paste: GeoJSON text when given, else the clipboard.
fn clip(s: &Session, p: &Value) -> Result<Dataset, String> {
match ps(p, "text").map(str::trim).filter(|t| !t.is_empty()) {
Some(t) if t.len() > MAX_TEXT => Err("the pasted text is too large".into()),
Some(t) => {
let (ds, _) =
rustgis_data::io::read_geojson(t, "Pasted").map_err(|e| format!("the pasted text isn't GeoJSON features: {e}"))?;
if ds.features.is_empty() { Err("the pasted text has no features".into()) } else { Ok(ds) }
}
None => s.clipboard.as_ref().map(|c| c.data.clone()).ok_or_else(|| "nothing has been copied".to_string()),
}
}

fn coerce(v: &Attr, ty: FieldType) -> Attr {
match v {
Attr::Null => Attr::Null,
Attr::Text(t) => Attr::parse(t, ty),
other => Attr::parse(&other.to_string(), ty),
}
}

fn paste(s: &mut Session, p: &Value) -> CmdResult {
let src = clip(s, p)?;
let id = s.layer_id(p)?;
let l = s.map_mut().layer_mut(id).ok_or("no such layer")?;
if l.is_raster() {
return Err("features can't be pasted into a raster layer".into());
}
let want = l.geometry_type();
if want != GeometryType::None && src.geometry_type != GeometryType::None && want != src.geometry_type {
return Err(format!(
"{} features can't go in a {} layer; use Paste Special to paste them as a new layer",
src.geometry_type.label(),
want.label()
));
}
// Fields are matched by name; the rest are left empty.
let map: Vec<Option<usize>> = l.dataset.fields.iter().map(|f| src.field_index(&f.name)).collect();
let mut next = l.dataset.features.iter().map(|f| f.id).max().unwrap_or(0);
let first = l.dataset.features.len();
for ft in src.features {
if ft.geometry.as_ref().is_some_and(|g| want != GeometryType::None && g.kind() != want) {
continue;
}
next = next.saturating_add(1);
let attrs = l
.dataset
.fields
.iter()
.zip(&map)
.map(|(f, i)| i.and_then(|i| ft.attrs.get(i)).map(|v| coerce(v, f.ty)).unwrap_or(Attr::Null))
.collect();
l.dataset.features.push(Feature { id: next, geometry: ft.geometry, attrs });
}
let n = l.dataset.features.len() - first;
l.touch_geometry();
l.selection = (first..first + n).collect();
let name = l.name.clone();
s.map_mut().layers.iter_mut().filter(|o| o.id != id).for_each(|o| o.selection.clear());
s.notify(format!("Pasted {n} features into {name}"), false);
Ok(json!({"pasted": n, "layer": id}))
}

fn paste_special(s: &mut Session, p: &Value) -> CmdResult {
let mut ds = clip(s, p)?;
if let Some(n) = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
ds.name = n.chars().take(200).collect();
} else {
ds.name = format!("{} (pasted)", ds.name);
}
ds.source = None;
let n = ds.features.len();
let id = super::layer::add_dataset(s, ds);
Ok(json!({"pasted": n, "layer": id}))
}

fn copy_path(s: &mut Session, p: &Value) -> CmdResult {
let id = s.layer_id(p)?;
let l = s.map().layer(id).ok_or("no such layer")?;
let path = l.raster.as_ref().and_then(|r| r.path.clone()).or_else(|| l.dataset.source.clone());
let path = path.ok_or_else(|| format!("{} isn't stored in a file", l.name))?;
Ok(json!({"text": path}))
}
149 changes: 149 additions & 0 deletions crates/engine/src/cmd/labels.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! Map › Labeling (lock, pause, unplaced labels, convert to annotation) and the Labeling tab's
//! visibility range and placement.
use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr};
use rustgis_map::{LabelPlacement, Renderer, Rgba, Symbol};
use serde_json::{Value, json};

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

pub fn commands() -> Vec<C> {
vec![
C::new("labels.lock", "Lock", "Map › Labeling", |s, p| {
let d = &mut s.map_mut().label_display;
d.locked = b(p, "on").unwrap_or(!d.locked);
Ok(json!({"locked": d.locked}))
})
.view()
.params(r#"{"on"?: bool}"#),
C::new("labels.pause", "Pause", "Map › Labeling", |s, p| {
let d = &mut s.map_mut().label_display;
d.paused = b(p, "on").unwrap_or(!d.paused);
Ok(json!({"paused": d.paused}))
})
.view()
.params(r#"{"on"?: bool}"#),
C::new("labels.viewUnplaced", "View Unplaced", "Map › Labeling", |s, p| {
let d = &mut s.map_mut().label_display;
d.show_unplaced = b(p, "on").unwrap_or(!d.show_unplaced);
Ok(json!({"showUnplaced": d.show_unplaced}))
})
.view()
.params(r#"{"on"?: bool}"#),
C::new("labels.convert", "Convert To Annotation", "Map › Labeling", convert)
.when(need_feature_layer)
.params(r#"{"layer"?, "name"?: string}"#),
C::new("labels.scaleRange", "Label Visibility Range", "Labeling › Visibility Range", scale_range)
.when(need_feature_layer)
.params(r#"{"layer"?, "min"?: scale|null (out beyond), "max"?: scale|null (in beyond), "clear"?: bool}"#),
C::new("labels.distanceRange", "Label Distance Range", "Labeling › Visibility Range", distance_range)
.when(need_feature_layer)
.params(r#"{"layer"?, "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(s, p, |l| l.labels.placement = pos)
})
.when(need_feature_layer)
.params(
r#"{"layer"?, "position": "auto"|"center"|"above"|"below"|"left"|"right"|"aboveRight"|"aboveLeft"|"belowRight"|"belowLeft"}"#,
),
]
}

/// A limit parameter: `Some(None)` clears it, `Some(Some(v))` sets it, `None` leaves it.
fn limit(p: &Value, k: &str) -> Result<Option<Option<f64>>, String> {
match p.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(t)) if t.trim().is_empty() => Ok(Some(None)),
Some(_) => match f(p, k) {
Some(v) if v > 0.0 => Ok(Some(Some(v.min(1e12)))),
Some(v) if v.abs() < f64::EPSILON => Ok(Some(None)),
_ => Err(format!("{k} must be a positive number")),
},
}
}

fn scale_range(s: &mut Session, p: &Value) -> CmdResult {
let clear = b(p, "clear").unwrap_or(false);
let (min, max) = (limit(p, "min")?, limit(p, "max")?);
with(s, p, |l| {
if clear {
l.labels.min_scale = None;
l.labels.max_scale = None;
l.labels.closest = None;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

labels.scaleRange with clear: true also resets the distance limits (closest/farthest), and labels.distanceRange doesn't reset the scale limits. The "Clear Limits" button is probably meant to clear both, but a script that calls labels.scaleRange {clear:true} to clear only the scale range will silently lose the distance range. Consider making the two commands clear only their own limits and having the UI button call both. Confidence: medium.

l.labels.farthest = None;
}
if let Some(v) = min {
l.labels.min_scale = v;
}
if let Some(v) = max {
l.labels.max_scale = v;
}
})
}

fn distance_range(s: &mut Session, p: &Value) -> CmdResult {
let clear = b(p, "clear").unwrap_or(false);
let (closest, farthest) = (limit(p, "closest")?, limit(p, "farthest")?);
with(s, p, |l| {
if clear {
l.labels.closest = None;
l.labels.farthest = None;
}
if let Some(v) = closest {
l.labels.closest = v;
}
if let Some(v) = farthest {
l.labels.farthest = v;
}
})
}

/// Converts a layer's labels into an annotation layer: one point per label, at the label point,
/// carrying the label text and drawn with the same text symbol. The layer's own labels turn off.
fn convert(s: &mut Session, p: &Value) -> CmdResult {
let id = s.layer_id(p)?;
let l = s.map().layer(id).ok_or("no such layer")?;
let fi = l.dataset.field_index(&l.labels.field).ok_or("the layer has no label field")?;
let rows = l.visible_rows();
let mut ds = Dataset::new(
ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(String::from).unwrap_or_else(|| format!("{} Annotation", l.name)),
GeometryType::Point,
vec![Field::new("TEXT", FieldType::Text)],
);
for (row, ft) in l.dataset.features.iter().enumerate() {
let v = l.dataset.value(row, fi);
if rows.get(row) != Some(&true) || v.is_null() {
continue;
}
if let Some(c) = ft.geometry.as_ref().and_then(Geometry::label_point) {
ds.push(Some(Geometry::Point(c)), vec![Attr::Text(v.to_string())]);
}
}
if ds.features.is_empty() {
return Err("the layer has no labels to convert".into());
}
let n = ds.features.len();
let mut labels = l.labels.clone();
labels.enabled = true;
labels.field = "TEXT".into();
labels.placement = LabelPlacement::Center;
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;
}
let new_id = super::layer::add_dataset(s, ds);
if let Some(a) = s.map_mut().layer_mut(new_id) {
// The annotation is all text: the points themselves are invisible.
let clear = Rgba::new(0, 0, 0, 0);
a.renderer = Renderer::Single(Symbol { fill: clear, stroke: clear, stroke_width: 0.0, size: 0.0, marker: Default::default() });
a.labels = labels;
a.min_scale = min_scale;
a.max_scale = max_scale;
}
Ok(json!({"layer": new_id, "annotations": n}))
}
Loading
Loading