Repository navigation
Add clipboard, pop-up modes and label display options #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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})) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| 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})) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
labels.scaleRangewithclear: truealso resets the distance limits (closest/farthest), andlabels.distanceRangedoesn't reset the scale limits. The "Clear Limits" button is probably meant to clear both, but a script that callslabels.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.